1use 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
29const 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
60pub struct NatsSink {
62 schema: SchemaRef,
63 config: Option<NatsSinkConfig>,
64 serializer: Option<Box<dyn RecordSerializer>>,
65 runtime: Option<Runtime>,
66 metrics: NatsSinkMetrics,
67 pending_acks: VecDeque<PublishAckFuture>,
70 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 (AckCertainty::OutcomeUnknown, false)
271 } else {
272 (AckCertainty::OutcomeUnknown, false)
276 }
277 }
278 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 #[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 #[must_use]
387 pub fn config(&self) -> Option<&NatsSinkConfig> {
388 self.config.as_ref()
389 }
390
391 #[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]
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 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 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 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 let expected_stream = cfg.stream.as_deref();
529 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 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 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 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 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 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
740async 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 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}