Skip to main content

laminar_connectors/nats/
sink.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
29/// A single replica survives a process restart when file-backed, but not a server/node loss.
30/// Three is the smallest `JetStream` quorum that preserves availability and one-fault durability.
31const MIN_DURABLE_REPLICAS: usize = 3;
32
33fn validate_durable_stream(
34    stream_name: &str,
35    config: &jetstream::stream::Config,
36) -> Result<(), ConnectorError> {
37    if config.storage != jetstream::stream::StorageType::File {
38        return Err(err(&format!(
39            "[LDB-5072] JetStream '{stream_name}' uses {:?} storage; durable at-least-once \
40             requires file-backed storage",
41            config.storage
42        )));
43    }
44    if config.num_replicas < MIN_DURABLE_REPLICAS {
45        return Err(err(&format!(
46            "[LDB-5073] JetStream '{stream_name}' has {} replica(s); production durable \
47             at-least-once requires at least {MIN_DURABLE_REPLICAS}",
48            config.num_replicas
49        )));
50    }
51    if config.no_ack {
52        return Err(err(&format!(
53            "[LDB-5074] JetStream '{stream_name}' disables publish acknowledgements; durable \
54             at-least-once requires broker acknowledgements"
55        )));
56    }
57    Ok(())
58}
59
60/// NATS sink — core and `JetStream` modes.
61pub struct NatsSink {
62    schema: SchemaRef,
63    config: Option<NatsSinkConfig>,
64    serializer: Option<Box<dyn RecordSerializer>>,
65    runtime: Option<Runtime>,
66    metrics: NatsSinkMetrics,
67    /// Drained in `flush`. A negative/ambiguous acknowledgement consumes its future, so every
68    /// drain error must propagate to the owning sink task's sticky epoch fence.
69    pending_acks: VecDeque<PublishAckFuture>,
70    /// Core publish only proves enqueue into the client. Keep that uncertainty across batches
71    /// until the client transport has flushed its write buffer.
72    core_dirty: bool,
73    task_owner: ConnectorTaskOwner,
74    task_tracker: ConnectorTaskTracker,
75}
76
77enum Runtime {
78    Core { client: Client },
79    JetStream { context: jetstream::Context },
80}
81
82async fn bounded_nats_setup_until<T, E>(
83    deadline: tokio::time::Instant,
84    total_timeout: Duration,
85    future: impl std::future::Future<Output = Result<T, E>>,
86    classify: impl FnOnce(E) -> ConnectorError,
87) -> Result<T, ConnectorError> {
88    match tokio::time::timeout_at(deadline, future).await {
89        Ok(Ok(value)) => Ok(value),
90        Ok(Err(error)) => Err(classify(error)),
91        Err(_) => Err(ConnectorError::Timeout(timeout_millis(total_timeout))),
92    }
93}
94
95#[derive(Debug)]
96enum PublishEnqueueFailure<E> {
97    Client(E),
98    TimedOut(Duration),
99}
100
101async fn bounded_publish_enqueue<T, E>(
102    deadline: tokio::time::Instant,
103    total_timeout: Duration,
104    future: impl std::future::Future<Output = Result<T, E>>,
105) -> Result<T, PublishEnqueueFailure<E>> {
106    match tokio::time::timeout_at(deadline, future).await {
107        Ok(Ok(value)) => Ok(value),
108        Ok(Err(error)) => Err(PublishEnqueueFailure::Client(error)),
109        Err(_) => Err(PublishEnqueueFailure::TimedOut(total_timeout)),
110    }
111}
112
113fn known_not_dispatched_error(
114    operation: &str,
115    detail: impl std::fmt::Display,
116    retryable: bool,
117    prior_output: bool,
118) -> ConnectorError {
119    if prior_output {
120        ConnectorError::outcome_unknown(
121            format!(
122                "{operation} did not dispatch the current message, but prior NATS output may \
123                 already have been applied: {detail}"
124            ),
125            retryable,
126        )
127    } else if retryable {
128        ConnectorError::WriteError(format!(
129            "{operation} did not dispatch the message: {detail}"
130        ))
131    } else {
132        ConnectorError::ConfigurationError(format!(
133            "{operation} rejected the message before dispatch: {detail}"
134        ))
135    }
136}
137
138fn preserve_prior_applied(error: ConnectorError, applied: usize) -> ConnectorError {
139    if applied == 0 || error.is_outcome_unknown() {
140        error
141    } else {
142        let retryable = error.is_transient();
143        ConnectorError::outcome_unknown(
144            format!(
145                "JetStream acknowledgement failed after {applied} earlier publish(es) were \
146                 acknowledged: {error}"
147            ),
148            retryable,
149        )
150    }
151}
152
153fn classify_core_publish_failure(
154    failure: PublishEnqueueFailure<async_nats::PublishError>,
155    prior_output: bool,
156) -> ConnectorError {
157    let (detail, retryable) = match failure {
158        PublishEnqueueFailure::Client(error) => {
159            let retryable = matches!(error.kind(), async_nats::client::PublishErrorKind::Send);
160            (error.to_string(), retryable)
161        }
162        PublishEnqueueFailure::TimedOut(timeout) => (
163            format!(
164                "client enqueue timed out after {}ms",
165                timeout_millis(timeout)
166            ),
167            true,
168        ),
169    };
170    known_not_dispatched_error("NATS core publish", detail, retryable, prior_output)
171}
172
173fn classify_jetstream_enqueue_failure(
174    failure: PublishEnqueueFailure<jetstream::context::PublishError>,
175    prior_output: bool,
176) -> ConnectorError {
177    let (detail, retryable) = match failure {
178        PublishEnqueueFailure::Client(error) => {
179            use jetstream::context::PublishErrorKind;
180
181            let invalid_subject = std::error::Error::source(&error)
182                .is_some_and(<dyn std::error::Error>::is::<async_nats::SubjectError>);
183            let retryable = match error.kind() {
184                PublishErrorKind::StreamNotFound
185                | PublishErrorKind::WrongLastMessageId
186                | PublishErrorKind::WrongLastSequence => false,
187                PublishErrorKind::Other if invalid_subject => false,
188                PublishErrorKind::TimedOut
189                | PublishErrorKind::BrokenPipe
190                | PublishErrorKind::MaxAckPending
191                | PublishErrorKind::Other => true,
192            };
193            (error.to_string(), retryable)
194        }
195        PublishEnqueueFailure::TimedOut(timeout) => (
196            format!(
197                "client enqueue timed out after {}ms",
198                timeout_millis(timeout)
199            ),
200            true,
201        ),
202    };
203    known_not_dispatched_error("NATS JetStream publish", detail, retryable, prior_output)
204}
205
206fn timeout_millis(timeout: Duration) -> u64 {
207    u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX)
208}
209
210async fn flush_core(
211    client: &Client,
212    timeout: Duration,
213    operation: &'static str,
214) -> Result<(), ConnectorError> {
215    match tokio::time::timeout(timeout, client.flush()).await {
216        Ok(Ok(())) => Ok(()),
217        Ok(Err(error)) => Err(ConnectorError::outcome_unknown(
218            format!(
219                "{operation} failed after Core publishes were enqueued; whether their bytes \
220                 reached the transport is unknown: {error}"
221            ),
222            true,
223        )),
224        Err(_) => Err(ConnectorError::outcome_unknown(
225            format!(
226                "{operation} timed out after {}ms with Core publishes still buffered or in flight",
227                timeout_millis(timeout)
228            ),
229            true,
230        )),
231    }
232}
233
234#[derive(Clone, Copy, Debug, PartialEq, Eq)]
235enum AckCertainty {
236    Rejected,
237    OutcomeUnknown,
238}
239
240#[derive(Debug)]
241struct AckFailure {
242    certainty: AckCertainty,
243    detail: String,
244    retryable: bool,
245}
246
247fn classify_jetstream_ack_failure(error: &jetstream::context::PublishError) -> AckFailure {
248    use jetstream::context::PublishErrorKind;
249
250    let source = std::error::Error::source(error);
251    let (certainty, retryable) = match error.kind() {
252        PublishErrorKind::StreamNotFound
253        | PublishErrorKind::WrongLastMessageId
254        | PublishErrorKind::WrongLastSequence => (AckCertainty::Rejected, false),
255        PublishErrorKind::TimedOut | PublishErrorKind::BrokenPipe => {
256            (AckCertainty::OutcomeUnknown, true)
257        }
258        PublishErrorKind::Other => {
259            if let Some(broker_error) =
260                source.and_then(|source| source.downcast_ref::<jetstream::Error>())
261            {
262                let retryable = matches!(broker_error.code(), 408 | 429 | 500..=599);
263                if retryable {
264                    (AckCertainty::OutcomeUnknown, true)
265                } else {
266                    (AckCertainty::Rejected, false)
267                }
268            } else if source.is_some_and(<dyn std::error::Error>::is::<serde_json::Error>) {
269                // A response arrived, but an invalid ack cannot prove whether persistence happened.
270                (AckCertainty::OutcomeUnknown, false)
271            } else {
272                // `PublishAckFuture` currently reports `Other` only with a broker error or a
273                // malformed acknowledgement as its source. An untyped fallback is a client
274                // contract violation, not a reason to retry forever.
275                (AckCertainty::OutcomeUnknown, false)
276            }
277        }
278        // This kind belongs to pre-dispatch semaphore acquisition. Seeing it on an ack future
279        // would violate the client contract, so fail closed rather than claim a rejection.
280        PublishErrorKind::MaxAckPending => (AckCertainty::OutcomeUnknown, false),
281    };
282    AckFailure {
283        certainty,
284        detail: error.to_string(),
285        retryable,
286    }
287}
288
289struct AckAggregate {
290    total: usize,
291    applied: usize,
292    rejected: usize,
293    ambiguous: usize,
294    first_error: Option<String>,
295    retryable: bool,
296}
297
298impl Default for AckAggregate {
299    fn default() -> Self {
300        Self {
301            total: 0,
302            applied: 0,
303            rejected: 0,
304            ambiguous: 0,
305            first_error: None,
306            retryable: true,
307        }
308    }
309}
310
311impl AckAggregate {
312    fn record_applied(&mut self) {
313        self.total += 1;
314        self.applied += 1;
315    }
316
317    fn record_failure(&mut self, failure: AckFailure) {
318        self.total += 1;
319        self.retryable &= failure.retryable;
320        match failure.certainty {
321            AckCertainty::Rejected => self.rejected += 1,
322            AckCertainty::OutcomeUnknown => self.ambiguous += 1,
323        }
324        self.first_error.get_or_insert(failure.detail);
325    }
326
327    fn record_unresolved_timeout(&mut self, count: usize, timeout: Duration) {
328        self.total += count;
329        self.ambiguous += count;
330        self.first_error.get_or_insert_with(|| {
331            format!(
332                "{count} acknowledgement(s) remained in flight after {}ms",
333                timeout_millis(timeout)
334            )
335        });
336    }
337
338    fn into_result(self) -> Result<usize, ConnectorError> {
339        if self.rejected == 0 && self.ambiguous == 0 {
340            return Ok(self.applied);
341        }
342        let detail = format!(
343            "{} rejected, {} outcome unknown, {} acknowledged out of {}; first error: {}",
344            self.rejected,
345            self.ambiguous,
346            self.applied,
347            self.total,
348            self.first_error.unwrap_or_else(|| "unknown".into())
349        );
350        if self.ambiguous > 0 || self.applied > 0 {
351            Err(ConnectorError::outcome_unknown(
352                format!("JetStream acknowledgement drain was not atomic: {detail}"),
353                self.retryable,
354            ))
355        } else if self.retryable {
356            Err(ConnectorError::WriteError(format!(
357                "JetStream rejected every publish acknowledgement: {detail}"
358            )))
359        } else {
360            Err(ConnectorError::ConfigurationError(format!(
361                "JetStream rejected every publish acknowledgement: {detail}"
362            )))
363        }
364    }
365}
366
367impl NatsSink {
368    /// Metrics register on `registry` if provided.
369    #[must_use]
370    pub fn new(schema: SchemaRef, registry: Option<&prometheus::Registry>) -> Self {
371        let (task_owner, task_tracker) = ConnectorTaskOwner::new();
372        Self {
373            schema,
374            config: None,
375            serializer: None,
376            runtime: None,
377            metrics: NatsSinkMetrics::new(registry),
378            pending_acks: VecDeque::new(),
379            core_dirty: false,
380            task_owner,
381            task_tracker,
382        }
383    }
384
385    /// Available after [`SinkConnector::open`].
386    #[must_use]
387    pub fn config(&self) -> Option<&NatsSinkConfig> {
388        self.config.as_ref()
389    }
390
391    /// Snapshot accessor for the prometheus-backed metrics struct.
392    #[must_use]
393    pub fn metrics_handle(&self) -> &NatsSinkMetrics {
394        &self.metrics
395    }
396}
397
398impl Drop for NatsSink {
399    fn drop(&mut self) {
400        retire_pending_acks(&mut self.pending_acks, &self.metrics, &self.task_owner);
401    }
402}
403
404// `async_trait` reports this cohesive lifecycle implementation as one generated function.
405#[async_trait]
406impl SinkConnector for NatsSink {
407    fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
408        Some(self.task_tracker.clone())
409    }
410
411    fn contract(&self, config: &ConnectorConfig) -> Result<SinkContract, ConnectorError> {
412        let cfg = NatsSinkConfig::from_config(config)?;
413        let consistency = match cfg.mode {
414            Mode::Core => SinkConsistency::Ephemeral,
415            Mode::JetStream => SinkConsistency::DurableAtLeastOnce,
416        };
417        Ok(SinkContract::new(
418            consistency,
419            SinkTopology::MultiWriter,
420            SinkInputMode::AppendOnly,
421        ))
422    }
423
424    async fn open(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
425        if self.core_dirty {
426            return Err(ConnectorError::InvalidState {
427                expected: "a new sink generation after an unresolved Core flush".into(),
428                actual: "this sink still has unconfirmed Core publishes".into(),
429            });
430        }
431        let cfg = NatsSinkConfig::from_config(config)?;
432        self.serializer = Some(
433            serde::create_serializer(cfg.format)
434                .map_err(|e| err(&format!("serializer for format {:?}: {e}", cfg.format)))?,
435        );
436        let setup_timeout = cfg.ack_timeout;
437        let setup_deadline = tokio::time::Instant::now() + setup_timeout;
438        let connect_options = track_connection_tasks(
439            build_connect_options(&cfg.auth, &cfg.tls)?,
440            &self.task_owner,
441            "sink",
442        )?;
443        let client = bounded_nats_setup_until(
444            setup_deadline,
445            setup_timeout,
446            Box::pin(connect_options.connect(&cfg.servers)),
447            |error| classify_connect_error(&error),
448        )
449        .await?;
450        self.runtime = Some(match cfg.mode {
451            Mode::Core => Runtime::Core { client },
452            Mode::JetStream => {
453                // Match the connector's retained-ack bound to the client's semaphore and fail
454                // immediately if the invariant is ever violated. Waiting for a permit while this
455                // connector owns every outstanding ack would deadlock the sink actor.
456                let context = jetstream::context::ContextBuilder::new()
457                    .timeout(cfg.ack_timeout)
458                    .ack_timeout(cfg.ack_timeout)
459                    .max_ack_inflight(cfg.max_pending)
460                    .backpressure_on_inflight(false)
461                    .build(client);
462                let stream_name = cfg
463                    .stream
464                    .as_deref()
465                    .expect("NatsSinkConfig validates the JetStream target");
466                let stream = bounded_nats_setup_until(
467                    setup_deadline,
468                    setup_timeout,
469                    context.get_stream(stream_name),
470                    |error| classify_get_stream_error(&error, stream_name),
471                )
472                .await?;
473                let info = stream.cached_info();
474                validate_durable_stream(stream_name, &info.config)?;
475                if cfg.dedup_id_column.is_some() {
476                    let actual = info.config.duplicate_window;
477                    if actual < cfg.min_duplicate_window {
478                        return Err(err(&format!(
479                            "[LDB-5056] stream '{stream_name}' has duplicate_window={actual:?}, \
480                             below the configured minimum {:?}. Replay or redelivery could land \
481                             outside the dedup horizon. Reconfigure the stream or lower \
482                             'min.duplicate.window.ms'.",
483                            cfg.min_duplicate_window,
484                        )));
485                    }
486                }
487                Runtime::JetStream { context }
488            }
489        });
490        self.config = Some(cfg);
491        Ok(())
492    }
493
494    async fn write_batch(&mut self, batch: &RecordBatch) -> Result<WriteResult, ConnectorError> {
495        // Split-borrow: `runtime` &mut, config/serializer &.
496        let Self {
497            config,
498            serializer,
499            runtime,
500            pending_acks,
501            core_dirty,
502            metrics,
503            task_owner,
504            task_tracker: _,
505            schema: _,
506        } = self;
507        let cfg = config.as_ref().ok_or_else(|| err("sink: open() first"))?;
508        // The runtime advertises this exact bound for the complete call. Every enqueue and
509        // acknowledgement wait shares it; row count cannot multiply the configured timeout.
510        let write_timeout = cfg.ack_timeout;
511        let write_deadline = tokio::time::Instant::now() + write_timeout;
512        let ser = serializer
513            .as_ref()
514            .ok_or_else(|| err("sink: open() first"))?;
515        let rt = runtime.as_mut().ok_or_else(|| err("sink: open() first"))?;
516
517        let subject_col = match &cfg.subject {
518            SubjectSpec::Column(name) => Some(resolve_utf8(batch, name)?),
519            SubjectSpec::Literal(_) => None,
520        };
521        let header_cols: Vec<(&HeaderName, &StringArray)> = cfg
522            .header_columns
523            .iter()
524            .map(|name| resolve_utf8(batch, name.as_ref()).map(|array| (name, array)))
525            .collect::<Result<_, _>>()?;
526        // The validated target also fences every publish. A subject that resolves to another
527        // stream fails at the broker instead of being durably acknowledged to the wrong target.
528        let expected_stream = cfg.stream.as_deref();
529        // `dedup.id.column` directly enables bounded `Nats-Msg-Id` deduplication. LDB-5056
530        // validates the stream's `duplicate_window` at open, but the sink contract remains
531        // durable at-least-once because broker dedup is not a coordinated checkpoint commit.
532        let dedup_col = cfg
533            .dedup_id_column
534            .as_deref()
535            .map(|n| resolve_utf8(batch, n).map(|arr| (n, arr)))
536            .transpose()?;
537
538        // Validate required per-row routing metadata before the first publish. A late null would
539        // otherwise turn a deterministic input error into a partially dispatched batch.
540        if let Some(arr) = subject_col {
541            let SubjectSpec::Column(name) = &cfg.subject else {
542                unreachable!("subject column exists only for a column subject")
543            };
544            validate_non_null(arr, "subject.column", name)?;
545        }
546        validate_publish_subjects(&cfg.subject, subject_col, batch.num_rows())?;
547        if let Some((name, arr)) = dedup_col {
548            validate_non_null(arr, "dedup.id.column", name)?;
549        }
550
551        let records = ser
552            .serialize(batch)
553            .map_err(|e| err(&format!("serialize batch: {e}")))?;
554        if records.len() != batch.num_rows() {
555            return Err(ConnectorError::Internal(format!(
556                "NATS serializer returned {} records for a {}-row batch",
557                records.len(),
558                batch.num_rows()
559            )));
560        }
561
562        // Validate every deterministic message property before the first publish. This keeps a
563        // malformed later row from turning an input/configuration error into a partially applied
564        // batch. async-nats checks plain payload size itself, but its header-publish path does not.
565        let max_payload = match &*rt {
566            Runtime::Core { client } => client.server_info().max_payload,
567            Runtime::JetStream { context } => context.client().server_info().max_payload,
568        };
569        for (row, payload) in records.iter().enumerate() {
570            let msg_id = dedup_col.map(|(_, array)| array.value(row));
571            let header_len =
572                validate_headers_and_encoded_len(expected_stream, msg_id, &header_cols, row)?;
573            validate_message_size(row, payload.len(), header_len, max_payload)?;
574        }
575
576        let mut bytes_total: u64 = 0;
577        let mut rows_written: usize = 0;
578        let mut acknowledged_in_write: usize = 0;
579        for (row, payload) in records.into_iter().enumerate() {
580            let subject = match (&cfg.subject, subject_col) {
581                (SubjectSpec::Literal(subject), _) => subject.as_str(),
582                (SubjectSpec::Column(_), Some(array)) => array.value(row),
583                (SubjectSpec::Column(_), None) => unreachable!("resolved above"),
584            };
585            let subject = Subject::from(subject);
586            let msg_id = dedup_col.map(|(_, array)| array.value(row));
587            let headers = build_headers(expected_stream, msg_id, &header_cols, row)?;
588            let payload = bytes::Bytes::from(payload);
589            let payload_len = payload.len() as u64;
590
591            match rt {
592                Runtime::Core { client } => {
593                    let prior_output = *core_dirty;
594                    let result = if let Some(h) = headers {
595                        bounded_publish_enqueue(
596                            write_deadline,
597                            write_timeout,
598                            client.publish_with_headers(subject, h, payload),
599                        )
600                        .await
601                    } else {
602                        bounded_publish_enqueue(
603                            write_deadline,
604                            write_timeout,
605                            client.publish(subject, payload),
606                        )
607                        .await
608                    };
609                    match result {
610                        Ok(()) => *core_dirty = true,
611                        Err(failure) => {
612                            metrics.record_publish_error();
613                            return Err(classify_core_publish_failure(failure, prior_output));
614                        }
615                    }
616                }
617                Runtime::JetStream { context } => {
618                    if pending_acks.len() >= cfg.max_pending {
619                        let acknowledged = drain_acks_until(
620                            pending_acks,
621                            metrics,
622                            task_owner,
623                            write_deadline,
624                            write_timeout,
625                        )
626                        .await
627                        .map_err(|error| preserve_prior_applied(error, acknowledged_in_write))?;
628                        acknowledged_in_write += acknowledged;
629                    }
630                    let prior_output = operation_has_prior_output(
631                        rows_written,
632                        pending_acks.len(),
633                        acknowledged_in_write,
634                    );
635                    let publish_result = if let Some(h) = headers {
636                        bounded_publish_enqueue(
637                            write_deadline,
638                            write_timeout,
639                            context.publish_with_headers(subject, h, payload),
640                        )
641                        .await
642                    } else {
643                        bounded_publish_enqueue(
644                            write_deadline,
645                            write_timeout,
646                            context.publish(subject, payload),
647                        )
648                        .await
649                    };
650                    match publish_result {
651                        Ok(fut) => pending_acks.push_back(fut),
652                        Err(failure) => {
653                            metrics.record_publish_error();
654                            metrics.set_pending_futures(pending_acks.len());
655                            return Err(classify_jetstream_enqueue_failure(failure, prior_output));
656                        }
657                    }
658                }
659            }
660
661            // Per-row so partial failures still credit successes.
662            metrics.record_published_row(payload_len);
663            rows_written += 1;
664            bytes_total += payload_len;
665        }
666
667        metrics.set_pending_futures(pending_acks.len());
668        Ok(WriteResult::new(rows_written, bytes_total))
669    }
670
671    fn schema(&self) -> SchemaRef {
672        self.schema.clone()
673    }
674
675    fn suggested_write_timeout(&self) -> Duration {
676        // The health deadline must include the broker acknowledgement window;
677        // otherwise the runtime cancels a healthy configured ack wait early.
678        self.config
679            .as_ref()
680            .map_or(Duration::from_secs(30), |config| config.ack_timeout)
681    }
682
683    async fn flush(&mut self) -> Result<(), ConnectorError> {
684        let timeout = self
685            .config
686            .as_ref()
687            .map_or(Duration::from_secs(30), |c| c.ack_timeout);
688        if let Some(Runtime::Core { client }) = self.runtime.as_ref() {
689            if !self.core_dirty {
690                return Ok(());
691            }
692            let result = flush_core(client, timeout, "NATS core flush").await;
693            if result.is_ok() {
694                self.core_dirty = false;
695            }
696            return result;
697        }
698        drain_acks(
699            &mut self.pending_acks,
700            &self.metrics,
701            &self.task_owner,
702            timeout,
703        )
704        .await
705        .map(|_| ())
706    }
707
708    async fn close(&mut self) -> Result<(), ConnectorError> {
709        let timeout = self
710            .config
711            .as_ref()
712            .map_or(Duration::from_secs(5), |c| c.ack_timeout);
713        let result = if let Some(Runtime::Core { client }) = self.runtime.as_ref() {
714            // async-nats buffers Core publishes client-side; empty its transport buffer before
715            // drop. This is not a broker acknowledgement, so Core remains explicitly ephemeral.
716            if self.core_dirty {
717                let result = flush_core(client, timeout, "NATS core close flush").await;
718                if result.is_ok() {
719                    self.core_dirty = false;
720                }
721                result
722            } else {
723                Ok(())
724            }
725        } else {
726            drain_acks(
727                &mut self.pending_acks,
728                &self.metrics,
729                &self.task_owner,
730                timeout,
731            )
732            .await
733            .map(|_| ())
734        };
735        self.runtime = None;
736        result
737    }
738}
739
740/// Drain `pending` concurrently, bounded by `timeout`. On deadline,
741/// each still-unresolved ack bumps `record_ack_error` once; the publish
742/// may have landed server-side. Broker dedup can suppress a replay within its
743/// configured window, but the sink contract remains durable at-least-once.
744async fn drain_acks(
745    pending: &mut VecDeque<PublishAckFuture>,
746    metrics: &NatsSinkMetrics,
747    task_owner: &ConnectorTaskOwner,
748    timeout: Duration,
749) -> Result<usize, ConnectorError> {
750    let deadline = tokio::time::Instant::now() + timeout;
751    drain_acks_until(pending, metrics, task_owner, deadline, timeout).await
752}
753
754async fn drain_acks_until(
755    pending: &mut VecDeque<PublishAckFuture>,
756    metrics: &NatsSinkMetrics,
757    task_owner: &ConnectorTaskOwner,
758    deadline: tokio::time::Instant,
759    total_timeout: Duration,
760) -> Result<usize, ConnectorError> {
761    if pending.is_empty() {
762        return Ok(0);
763    }
764    let set: FuturesUnordered<_> = pending.drain(..).map(IntoFuture::into_future).collect();
765    let task_guard = task_owner.track().ok_or_else(|| {
766        ConnectorError::Internal("NATS sink task generation is already retired".into())
767    })?;
768    let task_metrics = metrics.clone();
769    let (result_tx, result_rx) = tokio::sync::oneshot::channel();
770
771    // A caller deadline or cancellation must not drop unresolved PublishAckFutures: async-nats
772    // otherwise hands them to an internal acker whose JoinHandle is not exposed. The owned task
773    // returns the deadline result promptly, then lets every unresolved future reach the client's
774    // own timeout before releasing its generation guard.
775    drop(tokio::spawn(async move {
776        let _task_guard = task_guard;
777        run_ack_drain(set, task_metrics, deadline, total_timeout, result_tx).await;
778    }));
779
780    result_rx.await.map_err(|_| {
781        ConnectorError::Internal("NATS acknowledgement drain task terminated unexpectedly".into())
782    })?
783}
784
785fn retire_pending_acks(
786    pending: &mut VecDeque<PublishAckFuture>,
787    metrics: &NatsSinkMetrics,
788    task_owner: &ConnectorTaskOwner,
789) {
790    if pending.is_empty() {
791        return;
792    }
793    let count = pending.len();
794    let Some(task_guard) = task_owner.track() else {
795        for _ in 0..count {
796            metrics.record_ack_error();
797        }
798        pending.clear();
799        metrics.set_pending_futures(0);
800        return;
801    };
802    let Ok(runtime) = tokio::runtime::Handle::try_current() else {
803        drop(task_guard);
804        for _ in 0..count {
805            metrics.record_ack_error();
806        }
807        pending.clear();
808        metrics.set_pending_futures(0);
809        return;
810    };
811
812    let mut set: FuturesUnordered<_> = pending.drain(..).map(IntoFuture::into_future).collect();
813    let metrics = metrics.clone();
814    drop(runtime.spawn(async move {
815        let _task_guard = task_guard;
816        while let Some(result) = set.next().await {
817            match result {
818                Ok(ack) if ack.duplicate => metrics.record_dedup(),
819                Ok(_) => {}
820                Err(_) => metrics.record_ack_error(),
821            }
822        }
823        metrics.set_pending_futures(0);
824    }));
825}
826
827async fn run_ack_drain<F>(
828    mut set: FuturesUnordered<F>,
829    metrics: NatsSinkMetrics,
830    deadline: tokio::time::Instant,
831    total_timeout: Duration,
832    result_tx: tokio::sync::oneshot::Sender<Result<usize, ConnectorError>>,
833) where
834    F: std::future::Future<
835            Output = Result<jetstream::publish::PublishAck, jetstream::context::PublishError>,
836        > + Send
837        + 'static,
838{
839    let mut aggregate = AckAggregate::default();
840    loop {
841        if set.is_empty() {
842            metrics.set_pending_futures(0);
843            let _ = result_tx.send(aggregate.into_result());
844            return;
845        }
846        match tokio::time::timeout_at(deadline, set.next()).await {
847            Ok(Some(Ok(ack))) => {
848                aggregate.record_applied();
849                if ack.duplicate {
850                    metrics.record_dedup();
851                }
852            }
853            Ok(Some(Err(error))) => {
854                metrics.record_ack_error();
855                aggregate.record_failure(classify_jetstream_ack_failure(&error));
856            }
857            Ok(None) => {
858                metrics.set_pending_futures(0);
859                let _ = result_tx.send(aggregate.into_result());
860                return;
861            }
862            Err(_) => {
863                let unresolved = set.len();
864                for _ in 0..unresolved {
865                    metrics.record_ack_error();
866                }
867                aggregate.record_unresolved_timeout(unresolved, total_timeout);
868                metrics.set_pending_futures(unresolved);
869                let _ = result_tx.send(aggregate.into_result());
870
871                while let Some(result) = set.next().await {
872                    if let Ok(ack) = result {
873                        if ack.duplicate {
874                            metrics.record_dedup();
875                        }
876                    }
877                }
878                metrics.set_pending_futures(0);
879                return;
880            }
881        }
882    }
883}
884
885fn resolve_utf8<'a>(batch: &'a RecordBatch, name: &str) -> Result<&'a StringArray, ConnectorError> {
886    let col = batch
887        .column_by_name(name)
888        .ok_or_else(|| err(&format!("column '{name}' not in batch schema")))?;
889    col.as_string_opt::<i32>()
890        .ok_or_else(|| err(&format!("column '{name}' must be Utf8")))
891}
892
893fn validate_non_null(arr: &StringArray, kind: &str, name: &str) -> Result<(), ConnectorError> {
894    if arr.null_count() == 0 {
895        return Ok(());
896    }
897    let row = (0..arr.len())
898        .find(|&row| arr.is_null(row))
899        .expect("a positive null count has a null row");
900    Err(err(&format!("{kind} '{name}' is null at row {row}")))
901}
902
903fn operation_has_prior_output(
904    rows_enqueued: usize,
905    pending_acks: usize,
906    acknowledged_in_operation: usize,
907) -> bool {
908    rows_enqueued > 0 || pending_acks > 0 || acknowledged_in_operation > 0
909}
910
911fn validate_publish_subjects(
912    configured: &SubjectSpec,
913    subject_column: Option<&StringArray>,
914    rows: usize,
915) -> Result<(), ConnectorError> {
916    for row in 0..rows {
917        let subject = match (configured, subject_column) {
918            (SubjectSpec::Literal(subject), _) => subject.as_str(),
919            (SubjectSpec::Column(_), Some(column)) => column.value(row),
920            (SubjectSpec::Column(_), None) => {
921                unreachable!("subject column resolved before preflight")
922            }
923        };
924        validate_publish_subject(subject, row)?;
925    }
926    Ok(())
927}
928
929fn validate_publish_subject(subject: &str, row: usize) -> Result<(), ConnectorError> {
930    let bytes = subject.as_bytes();
931    let invalid = bytes.is_empty()
932        || bytes.first() == Some(&b'.')
933        || bytes.last() == Some(&b'.')
934        || bytes.windows(2).any(|pair| pair == b"..")
935        || bytes
936            .iter()
937            .any(|byte| matches!(byte, b' ' | b'\t' | b'\r' | b'\n' | b'*' | b'>'));
938    if invalid {
939        return Err(err(&format!(
940            "invalid NATS publish subject at row {row}: invalid subject format"
941        )));
942    }
943    Ok(())
944}
945
946fn header_entry_len(name: &str, value: &str) -> usize {
947    name.len()
948        .saturating_add(b": ".len())
949        .saturating_add(value.len())
950        .saturating_add(b"\r\n".len())
951}
952
953fn header_value_is_valid(value: &str) -> bool {
954    !value.contains(['\r', '\n'])
955}
956
957fn validate_headers_and_encoded_len(
958    expected_stream: Option<&str>,
959    msg_id: Option<&str>,
960    header_cols: &[(&HeaderName, &StringArray)],
961    row: usize,
962) -> Result<usize, ConnectorError> {
963    let mut entries_len = 0usize;
964    let mut has_headers = false;
965    if let Some(stream) = expected_stream {
966        if !header_value_is_valid(stream) {
967            return Err(err(&format!(
968                "invalid expected stream header at row {row}: value cannot contain CR or LF"
969            )));
970        }
971        has_headers = true;
972        entries_len = entries_len.saturating_add(header_entry_len("Nats-Expected-Stream", stream));
973    }
974    if let Some(id) = msg_id {
975        if !header_value_is_valid(id) {
976            return Err(err(&format!(
977                "invalid message deduplication id at row {row}: value cannot contain CR or LF"
978            )));
979        }
980        has_headers = true;
981        entries_len = entries_len.saturating_add(header_entry_len("Nats-Msg-Id", id));
982    }
983    for (name, array) in header_cols {
984        if array.is_null(row) {
985            continue;
986        }
987        let name: &str = (*name).as_ref();
988        let value = array.value(row);
989        if !header_value_is_valid(value) {
990            return Err(err(&format!(
991                "invalid header '{name}' value at row {row}: value cannot contain CR or LF"
992            )));
993        }
994        has_headers = true;
995        entries_len = entries_len.saturating_add(header_entry_len(name, value));
996    }
997    if has_headers {
998        Ok(b"NATS/1.0\r\n"
999            .len()
1000            .saturating_add(entries_len)
1001            .saturating_add(b"\r\n".len()))
1002    } else {
1003        Ok(0)
1004    }
1005}
1006
1007#[cfg(test)]
1008fn encoded_header_len(headers: &HeaderMap) -> usize {
1009    if headers.is_empty() {
1010        return 0;
1011    }
1012    let mut len = b"NATS/1.0\r\n".len() + b"\r\n".len();
1013    for (name, values) in headers.iter() {
1014        let name: &str = name.as_ref();
1015        for value in values {
1016            len = len
1017                .saturating_add(name.len())
1018                .saturating_add(b": ".len())
1019                .saturating_add(value.as_str().len())
1020                .saturating_add(b"\r\n".len());
1021        }
1022    }
1023    len
1024}
1025
1026fn validate_message_size(
1027    row: usize,
1028    payload_len: usize,
1029    header_len: usize,
1030    max_payload: usize,
1031) -> Result<(), ConnectorError> {
1032    let message_len = payload_len.saturating_add(header_len);
1033    if message_len > max_payload {
1034        return Err(err(&format!(
1035            "NATS message at row {row} is {message_len} bytes including headers, above the current server max_payload of {max_payload} bytes"
1036        )));
1037    }
1038    Ok(())
1039}
1040
1041fn parse_header_value(kind: &str, value: &str, row: usize) -> Result<HeaderValue, ConnectorError> {
1042    HeaderValue::from_str(value)
1043        .map_err(|error| err(&format!("invalid {kind} at row {row}: {error}")))
1044}
1045
1046fn build_headers(
1047    expected_stream: Option<&str>,
1048    msg_id: Option<&str>,
1049    header_cols: &[(&HeaderName, &StringArray)],
1050    row: usize,
1051) -> Result<Option<HeaderMap>, ConnectorError> {
1052    if header_cols.is_empty() && expected_stream.is_none() && msg_id.is_none() {
1053        return Ok(None);
1054    }
1055    let mut h = HeaderMap::new();
1056    if let Some(s) = expected_stream {
1057        h.insert(
1058            HeaderName::from_static("Nats-Expected-Stream"),
1059            parse_header_value("expected stream header", s, row)?,
1060        );
1061    }
1062    if let Some(id) = msg_id {
1063        h.insert(
1064            HeaderName::from_static("Nats-Msg-Id"),
1065            parse_header_value("message deduplication id", id, row)?,
1066        );
1067    }
1068    for (name, arr) in header_cols {
1069        if !arr.is_null(row) {
1070            let header_name: &str = (*name).as_ref();
1071            let value = parse_header_value(
1072                &format!("header '{header_name}' value"),
1073                arr.value(row),
1074                row,
1075            )?;
1076            h.insert((*name).clone(), value);
1077        }
1078    }
1079    Ok(Some(h))
1080}
1081
1082fn err(msg: &str) -> ConnectorError {
1083    ConnectorError::ConfigurationError(msg.to_string())
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088    use super::*;
1089
1090    fn sink_config(mode: Mode) -> ConnectorConfig {
1091        let mut config = ConnectorConfig::new("nats");
1092        config.set("servers", "nats://localhost:4222");
1093        config.set("subject", "events");
1094        config.set(
1095            "mode",
1096            match mode {
1097                Mode::Core => "core",
1098                Mode::JetStream => "jetstream",
1099            },
1100        );
1101        if mode == Mode::JetStream {
1102            config.set("stream", "EVENTS");
1103        }
1104        config
1105    }
1106
1107    fn durable_stream_config() -> jetstream::stream::Config {
1108        jetstream::stream::Config {
1109            name: "EVENTS".into(),
1110            storage: jetstream::stream::StorageType::File,
1111            num_replicas: MIN_DURABLE_REPLICAS,
1112            no_ack: false,
1113            ..Default::default()
1114        }
1115    }
1116
1117    fn broker_error(code: u16) -> jetstream::Error {
1118        serde_json::from_value(serde_json::json!({
1119            "code": code,
1120            "err_code": 10008,
1121            "description": "test response"
1122        }))
1123        .unwrap()
1124    }
1125
1126    #[test]
1127    fn core_contract_is_ephemeral_multi_writer() {
1128        let sink = NatsSink::new(std::sync::Arc::new(arrow_schema::Schema::empty()), None);
1129        let contract = sink.contract(&sink_config(Mode::Core)).unwrap();
1130        assert_eq!(contract.consistency, SinkConsistency::Ephemeral);
1131        assert_eq!(contract.topology, SinkTopology::MultiWriter);
1132        assert_eq!(contract.input_mode, SinkInputMode::AppendOnly);
1133    }
1134
1135    #[test]
1136    fn sink_generation_exposes_terminal_task_proof() {
1137        let sink = NatsSink::new(std::sync::Arc::new(arrow_schema::Schema::empty()), None);
1138        let terminal = sink.terminal_task_tracker().unwrap();
1139        assert!(!terminal.is_terminated());
1140        assert_eq!(
1141            sink.cancellation_policy(),
1142            crate::connector::ConnectorCancellationPolicy::RetireConnector
1143        );
1144
1145        drop(sink);
1146
1147        assert!(terminal.is_terminated());
1148    }
1149
1150    #[test]
1151    fn named_jetstream_contract_is_durable_pending_open_validation() {
1152        let sink = NatsSink::new(std::sync::Arc::new(arrow_schema::Schema::empty()), None);
1153        let contract = sink.contract(&sink_config(Mode::JetStream)).unwrap();
1154        assert_eq!(contract.consistency, SinkConsistency::DurableAtLeastOnce);
1155        assert_eq!(contract.topology, SinkTopology::MultiWriter);
1156    }
1157
1158    #[test]
1159    fn durable_stream_validation_accepts_file_quorum_with_acks() {
1160        validate_durable_stream("EVENTS", &durable_stream_config()).unwrap();
1161    }
1162
1163    #[test]
1164    fn durable_stream_validation_rejects_memory_storage() {
1165        let mut config = durable_stream_config();
1166        config.storage = jetstream::stream::StorageType::Memory;
1167        let error = validate_durable_stream("EVENTS", &config).unwrap_err();
1168        assert!(error.to_string().contains("LDB-5072"));
1169    }
1170
1171    #[test]
1172    fn durable_stream_validation_rejects_non_quorum_replication() {
1173        let mut config = durable_stream_config();
1174        config.num_replicas = MIN_DURABLE_REPLICAS - 1;
1175        let error = validate_durable_stream("EVENTS", &config).unwrap_err();
1176        assert!(error.to_string().contains("LDB-5073"));
1177    }
1178
1179    #[test]
1180    fn durable_stream_validation_rejects_disabled_acks() {
1181        let mut config = durable_stream_config();
1182        config.no_ack = true;
1183        let error = validate_durable_stream("EVENTS", &config).unwrap_err();
1184        assert!(error.to_string().contains("LDB-5074"));
1185    }
1186
1187    #[tokio::test(start_paused = true)]
1188    async fn enqueue_timeout_is_definite_for_current_message_and_bounded() {
1189        let timeout = Duration::from_millis(25);
1190        let started = tokio::time::Instant::now();
1191        let failure = bounded_publish_enqueue(
1192            started + timeout,
1193            timeout,
1194            std::future::pending::<Result<(), async_nats::PublishError>>(),
1195        )
1196        .await
1197        .unwrap_err();
1198        let error = classify_core_publish_failure(failure, false);
1199        assert!(matches!(error, ConnectorError::WriteError(_)));
1200        assert!(!error.is_outcome_unknown());
1201        assert_eq!(tokio::time::Instant::now() - started, timeout);
1202
1203        let setup_error = bounded_nats_setup_until(
1204            tokio::time::Instant::now() + timeout,
1205            timeout,
1206            std::future::pending::<Result<(), async_nats::ConnectError>>(),
1207            |error| classify_connect_error(&error),
1208        )
1209        .await
1210        .unwrap_err();
1211        assert!(matches!(setup_error, ConnectorError::Timeout(25)));
1212        assert!(!setup_error.is_outcome_unknown());
1213    }
1214
1215    #[tokio::test(start_paused = true)]
1216    async fn setup_steps_share_one_absolute_admission_deadline() {
1217        let timeout = Duration::from_millis(25);
1218        let started = tokio::time::Instant::now();
1219        let deadline = started + timeout;
1220
1221        bounded_nats_setup_until(
1222            deadline,
1223            timeout,
1224            async {
1225                tokio::time::sleep(Duration::from_millis(15)).await;
1226                Ok::<_, async_nats::ConnectError>(())
1227            },
1228            |error| classify_connect_error(&error),
1229        )
1230        .await
1231        .unwrap();
1232        let error = bounded_nats_setup_until(
1233            deadline,
1234            timeout,
1235            std::future::pending::<Result<(), async_nats::ConnectError>>(),
1236            |error| classify_connect_error(&error),
1237        )
1238        .await
1239        .unwrap_err();
1240
1241        assert!(matches!(error, ConnectorError::Timeout(25)));
1242        assert_eq!(tokio::time::Instant::now() - started, timeout);
1243    }
1244
1245    #[tokio::test(start_paused = true)]
1246    async fn enqueue_windows_share_one_absolute_write_deadline() {
1247        let timeout = Duration::from_millis(25);
1248        let started = tokio::time::Instant::now();
1249        let deadline = started + timeout;
1250
1251        bounded_publish_enqueue(deadline, timeout, async {
1252            tokio::time::sleep(Duration::from_millis(15)).await;
1253            Ok::<_, async_nats::PublishError>(())
1254        })
1255        .await
1256        .unwrap();
1257        let failure = bounded_publish_enqueue(
1258            deadline,
1259            timeout,
1260            std::future::pending::<Result<(), async_nats::PublishError>>(),
1261        )
1262        .await
1263        .unwrap_err();
1264
1265        assert!(matches!(failure, PublishEnqueueFailure::TimedOut(value) if value == timeout));
1266        assert_eq!(tokio::time::Instant::now() - started, timeout);
1267    }
1268
1269    #[test]
1270    fn core_enqueue_errors_are_definite_until_prior_output_exists() {
1271        use async_nats::client::PublishErrorKind;
1272
1273        let invalid = classify_core_publish_failure(
1274            PublishEnqueueFailure::Client(async_nats::PublishError::new(
1275                PublishErrorKind::InvalidSubject,
1276            )),
1277            false,
1278        );
1279        assert!(matches!(invalid, ConnectorError::ConfigurationError(_)));
1280        assert!(!invalid.is_outcome_unknown());
1281
1282        let disconnected = classify_core_publish_failure(
1283            PublishEnqueueFailure::Client(async_nats::PublishError::new(PublishErrorKind::Send)),
1284            false,
1285        );
1286        assert!(matches!(disconnected, ConnectorError::WriteError(_)));
1287        assert!(disconnected.is_transient());
1288
1289        let partial = classify_core_publish_failure(
1290            PublishEnqueueFailure::Client(async_nats::PublishError::new(
1291                PublishErrorKind::InvalidSubject,
1292            )),
1293            true,
1294        );
1295        assert!(partial.is_outcome_unknown());
1296        assert!(!partial.is_transient());
1297    }
1298
1299    #[test]
1300    fn dynamic_headers_are_validated_before_publish() {
1301        let name = HeaderName::from_str("trace_id").unwrap();
1302        let values = StringArray::from(vec![Some("valid"), Some("invalid\r\nvalue")]);
1303
1304        let error =
1305            validate_headers_and_encoded_len(None, None, &[(&name, &values)], 1).unwrap_err();
1306        assert!(matches!(error, ConnectorError::ConfigurationError(_)));
1307        assert!(error.to_string().contains("row 1"));
1308    }
1309
1310    #[test]
1311    fn later_row_publish_wildcard_is_rejected_by_batch_preflight() {
1312        let subjects = StringArray::from(vec!["events.valid", "events.*", "never.reached"]);
1313        let configured = SubjectSpec::Column("subject".into());
1314
1315        let error = validate_publish_subjects(&configured, Some(&subjects), subjects.len())
1316            .expect_err("publish wildcards are subscriptions, never concrete targets");
1317
1318        assert!(matches!(error, ConnectorError::ConfigurationError(_)));
1319        assert!(error.to_string().contains("row 1"));
1320    }
1321
1322    #[test]
1323    fn max_payload_preflight_includes_encoded_headers() {
1324        let name = HeaderName::from_str("trace_id").unwrap();
1325        let values = StringArray::from(vec![Some("abc")]);
1326        let headers = build_headers(None, None, &[(&name, &values)], 0)
1327            .unwrap()
1328            .unwrap();
1329        let encoded_len =
1330            validate_headers_and_encoded_len(None, None, &[(&name, &values)], 0).unwrap();
1331        assert_eq!(encoded_len, 27);
1332        assert_eq!(encoded_header_len(&headers), 27);
1333
1334        validate_message_size(0, 5, encoded_len, encoded_len + 5).unwrap();
1335        let error = validate_message_size(0, 5, encoded_len, encoded_len + 4).unwrap_err();
1336        assert!(matches!(error, ConnectorError::ConfigurationError(_)));
1337        assert!(error.to_string().contains("including headers"));
1338    }
1339
1340    #[test]
1341    fn acknowledged_prior_batch_makes_later_enqueue_failure_partial() {
1342        assert!(operation_has_prior_output(0, 0, 1));
1343        let error = classify_jetstream_enqueue_failure(
1344            PublishEnqueueFailure::Client(jetstream::context::PublishError::new(
1345                jetstream::context::PublishErrorKind::StreamNotFound,
1346            )),
1347            operation_has_prior_output(0, 0, 1),
1348        );
1349        assert!(error.is_outcome_unknown());
1350        assert!(!error.is_transient());
1351    }
1352
1353    #[tokio::test]
1354    async fn unresolved_core_generation_cannot_be_reopened() {
1355        let mut sink = NatsSink::new(std::sync::Arc::new(arrow_schema::Schema::empty()), None);
1356        sink.core_dirty = true;
1357
1358        let error = sink.open(&sink_config(Mode::Core)).await.unwrap_err();
1359        assert!(matches!(error, ConnectorError::InvalidState { .. }));
1360    }
1361
1362    #[test]
1363    fn jetstream_enqueue_timeout_is_not_an_ack_timeout() {
1364        use jetstream::context::{PublishError, PublishErrorKind};
1365
1366        let current = classify_jetstream_enqueue_failure(
1367            PublishEnqueueFailure::Client(PublishError::new(PublishErrorKind::TimedOut)),
1368            false,
1369        );
1370        assert!(matches!(current, ConnectorError::WriteError(_)));
1371        assert!(!current.is_outcome_unknown());
1372
1373        let partial = classify_jetstream_enqueue_failure(
1374            PublishEnqueueFailure::Client(PublishError::new(PublishErrorKind::TimedOut)),
1375            true,
1376        );
1377        assert!(partial.is_outcome_unknown());
1378        assert!(partial.is_transient());
1379    }
1380
1381    #[test]
1382    fn jetstream_ack_classifier_distinguishes_rejection_and_ambiguity() {
1383        use jetstream::context::{PublishError, PublishErrorKind};
1384
1385        let rejected =
1386            classify_jetstream_ack_failure(&PublishError::new(PublishErrorKind::StreamNotFound));
1387        assert_eq!(rejected.certainty, AckCertainty::Rejected);
1388        assert!(!rejected.retryable);
1389
1390        for kind in [PublishErrorKind::TimedOut, PublishErrorKind::BrokenPipe] {
1391            let ambiguous = classify_jetstream_ack_failure(&PublishError::new(kind));
1392            assert_eq!(ambiguous.certainty, AckCertainty::OutcomeUnknown);
1393            assert!(ambiguous.retryable);
1394        }
1395
1396        for code in [408, 429, 500, 503, 599] {
1397            let ambiguous = classify_jetstream_ack_failure(&PublishError::with_source(
1398                PublishErrorKind::Other,
1399                broker_error(code),
1400            ));
1401            assert_eq!(ambiguous.certainty, AckCertainty::OutcomeUnknown);
1402            assert!(ambiguous.retryable);
1403        }
1404        for code in [400, 401, 403, 404, 422] {
1405            let rejected = classify_jetstream_ack_failure(&PublishError::with_source(
1406                PublishErrorKind::Other,
1407                broker_error(code),
1408            ));
1409            assert_eq!(rejected.certainty, AckCertainty::Rejected);
1410            assert!(!rejected.retryable);
1411        }
1412
1413        let malformed_json = serde_json::from_slice::<serde_json::Value>(b"{").unwrap_err();
1414        let malformed = classify_jetstream_ack_failure(&PublishError::with_source(
1415            PublishErrorKind::Other,
1416            malformed_json,
1417        ));
1418        assert_eq!(malformed.certainty, AckCertainty::OutcomeUnknown);
1419        assert!(!malformed.retryable);
1420
1421        for impossible in [PublishErrorKind::Other, PublishErrorKind::MaxAckPending] {
1422            let failure = classify_jetstream_ack_failure(&PublishError::new(impossible));
1423            assert_eq!(failure.certainty, AckCertainty::OutcomeUnknown);
1424            assert!(!failure.retryable);
1425        }
1426    }
1427
1428    #[test]
1429    fn ack_aggregation_returns_definite_error_when_all_are_rejected() {
1430        let mut aggregate = AckAggregate::default();
1431        aggregate.record_failure(AckFailure {
1432            certainty: AckCertainty::Rejected,
1433            detail: "stream rejected publish".into(),
1434            retryable: false,
1435        });
1436
1437        let error = aggregate.into_result().unwrap_err();
1438        assert!(matches!(error, ConnectorError::ConfigurationError(_)));
1439        assert!(!error.is_outcome_unknown());
1440    }
1441
1442    #[test]
1443    fn ack_aggregation_makes_partial_success_sticky() {
1444        let mut aggregate = AckAggregate::default();
1445        aggregate.record_applied();
1446        aggregate.record_failure(AckFailure {
1447            certainty: AckCertainty::Rejected,
1448            detail: "stream rejected publish".into(),
1449            retryable: false,
1450        });
1451
1452        let error = aggregate.into_result().unwrap_err();
1453        assert!(error.is_outcome_unknown());
1454        assert!(!error.is_transient());
1455        assert!(error.to_string().contains("1 acknowledged"));
1456    }
1457
1458    #[test]
1459    fn prior_successful_drain_makes_later_rejection_sticky() {
1460        let rejected = ConnectorError::ConfigurationError("stream rejected publish".into());
1461        let error = preserve_prior_applied(rejected, 2);
1462
1463        assert!(error.is_outcome_unknown());
1464        assert!(!error.is_transient());
1465        assert!(error.to_string().contains("2 earlier publish(es)"));
1466    }
1467
1468    #[test]
1469    fn ack_aggregation_gives_ambiguity_correctness_precedence() {
1470        let mut aggregate = AckAggregate::default();
1471        aggregate.record_failure(AckFailure {
1472            certainty: AckCertainty::Rejected,
1473            detail: "stream rejected publish".into(),
1474            retryable: false,
1475        });
1476        aggregate.record_unresolved_timeout(2, Duration::from_millis(50));
1477
1478        let error = aggregate.into_result().unwrap_err();
1479        assert!(error.is_outcome_unknown());
1480        assert!(!error.is_transient());
1481        assert!(error.to_string().contains("2 outcome unknown"));
1482    }
1483}