1use std::str::FromStr;
4use std::sync::atomic::{AtomicUsize, Ordering};
5use std::sync::{Arc, Weak};
6
7use arrow_array::RecordBatch;
8use arrow_schema::SchemaRef;
9use async_trait::async_trait;
10use sha2::{Digest, Sha256};
11use tokio::sync::Notify;
12
13use crate::checkpoint::SourceCheckpoint;
14use crate::config::ConnectorConfig;
15use crate::error::ConnectorError;
16
17#[derive(
23 Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize,
24)]
25#[serde(rename_all = "snake_case")]
26pub enum DeliveryGuarantee {
27 #[default]
30 BestEffort,
31 AtLeastOnce,
34 ExactlyOnce,
38}
39
40impl std::fmt::Display for DeliveryGuarantee {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 match self {
43 DeliveryGuarantee::BestEffort => write!(f, "best-effort"),
44 DeliveryGuarantee::AtLeastOnce => write!(f, "at-least-once"),
45 DeliveryGuarantee::ExactlyOnce => write!(f, "exactly-once"),
46 }
47 }
48}
49
50impl FromStr for DeliveryGuarantee {
51 type Err = String;
52
53 fn from_str(s: &str) -> Result<Self, Self::Err> {
54 match s.to_lowercase().replace('-', "_").as_str() {
55 "best_effort" | "besteffort" | "none" => Ok(Self::BestEffort),
56 "at_least_once" | "atleastonce" => Ok(Self::AtLeastOnce),
57 "exactly_once" | "exactlyonce" => Ok(Self::ExactlyOnce),
58 other => Err(format!("unknown delivery guarantee: '{other}'")),
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
69pub enum SourceConsistency {
70 #[default]
72 Ephemeral,
73 Replayable,
75 CommitCoupled,
78}
79
80impl SourceConsistency {
81 #[must_use]
83 pub const fn supports_replay(self) -> bool {
84 !matches!(self, Self::Ephemeral)
85 }
86
87 #[must_use]
89 pub const fn requires_checkpointing(self) -> bool {
90 matches!(self, Self::CommitCoupled)
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
96pub enum SourceTopology {
97 #[default]
99 Singleton,
100 Splittable,
102 NodeLocalIngress,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
108pub struct SourceContract {
109 pub consistency: SourceConsistency,
111 pub topology: SourceTopology,
113 exact_delivery_certified: bool,
114}
115
116impl SourceContract {
117 #[must_use]
120 pub const fn new(consistency: SourceConsistency, topology: SourceTopology) -> Self {
121 Self {
122 consistency,
123 topology,
124 exact_delivery_certified: false,
125 }
126 }
127
128 #[must_use]
130 pub(crate) const fn with_exact_delivery_certification(mut self) -> Self {
131 self.exact_delivery_certified = true;
132 self
133 }
134
135 #[doc(hidden)]
137 #[must_use]
138 pub const fn is_exact_delivery_certified(self) -> bool {
139 self.exact_delivery_certified
140 }
141
142 #[must_use]
144 pub const fn supports_replay(self) -> bool {
145 self.consistency.supports_replay()
146 }
147
148 #[must_use]
150 pub const fn requires_checkpointing(self) -> bool {
151 self.consistency.requires_checkpointing()
152 }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
160pub enum SinkConsistency {
161 #[default]
163 Ephemeral,
164 DurableAtLeastOnce,
167 CheckpointCommittable,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
175pub enum SinkTopology {
176 #[default]
178 Singleton,
179 MultiWriter,
181 NodeLocalEgress,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
187pub enum SinkInputMode {
188 #[default]
190 AppendOnly,
191 KeyedUpsert,
194 FullChangelog,
196}
197
198impl SinkInputMode {
199 #[must_use]
202 pub const fn accepts_full_changelog(self) -> bool {
203 matches!(self, Self::FullChangelog)
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
209pub struct SinkContract {
210 pub consistency: SinkConsistency,
212 pub topology: SinkTopology,
214 pub input_mode: SinkInputMode,
216}
217
218impl SinkContract {
219 #[must_use]
221 pub const fn new(
222 consistency: SinkConsistency,
223 topology: SinkTopology,
224 input_mode: SinkInputMode,
225 ) -> Self {
226 Self {
227 consistency,
228 topology,
229 input_mode,
230 }
231 }
232
233 #[must_use]
235 pub const fn is_checkpoint_committable(self) -> bool {
236 matches!(self.consistency, SinkConsistency::CheckpointCommittable)
237 }
238
239 #[must_use]
241 pub const fn accepts_full_changelog(self) -> bool {
242 self.input_mode.accepts_full_changelog()
243 }
244}
245
246#[derive(Debug, Clone)]
248pub struct SourceBatch {
249 pub records: RecordBatch,
251}
252
253impl SourceBatch {
254 #[must_use]
256 pub fn new(records: RecordBatch) -> Self {
257 Self { records }
258 }
259
260 #[must_use]
262 pub fn num_rows(&self) -> usize {
263 self.records.num_rows()
264 }
265}
266
267#[derive(Debug, Clone)]
269pub struct WriteResult {
270 pub records_written: usize,
272 pub bytes_written: u64,
274}
275
276impl WriteResult {
277 #[must_use]
279 pub fn new(records_written: usize, bytes_written: u64) -> Self {
280 Self {
281 records_written,
282 bytes_written,
283 }
284 }
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum ConnectorCancellationPolicy {
295 CancelSafe,
297 RetireConnector,
300}
301
302const CONNECTOR_TASK_OWNER_DROPPED: usize = 1usize << (usize::BITS - 1);
303
304struct ConnectorTaskState {
305 state: AtomicUsize,
306 terminated: Notify,
307}
308
309pub struct ConnectorTaskOwner {
314 inner: Arc<ConnectorTaskState>,
315}
316
317#[derive(Clone)]
323pub struct ConnectorTaskAdmission {
324 inner: Weak<ConnectorTaskState>,
325}
326
327#[derive(Clone)]
329pub struct ConnectorTaskTracker {
330 inner: Arc<ConnectorTaskState>,
331}
332
333#[must_use = "dropping the guard marks its connector task complete"]
338pub struct ConnectorTaskGuard {
339 inner: Arc<ConnectorTaskState>,
340}
341
342impl ConnectorTaskOwner {
343 #[must_use]
345 pub fn new() -> (Self, ConnectorTaskTracker) {
346 let inner = Arc::new(ConnectorTaskState {
347 state: AtomicUsize::new(0),
348 terminated: Notify::new(),
349 });
350 (
351 Self {
352 inner: Arc::clone(&inner),
353 },
354 ConnectorTaskTracker { inner },
355 )
356 }
357
358 #[must_use]
364 pub fn admission(&self) -> ConnectorTaskAdmission {
365 ConnectorTaskAdmission {
366 inner: Arc::downgrade(&self.inner),
367 }
368 }
369
370 #[must_use]
375 pub fn track(&self) -> Option<ConnectorTaskGuard> {
376 track_connector_task(&self.inner)
377 }
378}
379
380impl ConnectorTaskAdmission {
381 #[must_use]
386 pub fn track(&self) -> Option<ConnectorTaskGuard> {
387 let inner = self.inner.upgrade()?;
388 track_connector_task(&inner)
389 }
390}
391
392fn track_connector_task(inner: &Arc<ConnectorTaskState>) -> Option<ConnectorTaskGuard> {
393 let mut observed = inner.state.load(Ordering::Acquire);
394 loop {
395 if observed & CONNECTOR_TASK_OWNER_DROPPED != 0 {
396 return None;
397 }
398 let next = observed.checked_add(1)?;
399 if next & CONNECTOR_TASK_OWNER_DROPPED != 0 {
400 return None;
401 }
402 match inner
403 .state
404 .compare_exchange_weak(observed, next, Ordering::AcqRel, Ordering::Acquire)
405 {
406 Ok(_) => {
407 return Some(ConnectorTaskGuard {
408 inner: Arc::clone(inner),
409 });
410 }
411 Err(actual) => observed = actual,
412 }
413 }
414}
415
416impl Drop for ConnectorTaskOwner {
417 fn drop(&mut self) {
418 let previous = self
419 .inner
420 .state
421 .fetch_or(CONNECTOR_TASK_OWNER_DROPPED, Ordering::AcqRel);
422 debug_assert_eq!(previous & CONNECTOR_TASK_OWNER_DROPPED, 0);
423 if previous == 0 {
424 self.inner.terminated.notify_waiters();
425 }
426 }
427}
428
429impl ConnectorTaskTracker {
430 #[must_use]
432 pub fn is_terminated(&self) -> bool {
433 self.inner.state.load(Ordering::Acquire) == CONNECTOR_TASK_OWNER_DROPPED
434 }
435
436 pub async fn wait_terminated(&self) {
438 loop {
439 let notified = self.inner.terminated.notified();
440 tokio::pin!(notified);
441 notified.as_mut().enable();
442 if self.is_terminated() {
443 return;
444 }
445 notified.await;
446 }
447 }
448}
449
450impl Drop for ConnectorTaskGuard {
451 fn drop(&mut self) {
452 let previous = self.inner.state.fetch_sub(1, Ordering::AcqRel);
453 debug_assert_ne!(previous & !CONNECTOR_TASK_OWNER_DROPPED, 0);
454 if previous == CONNECTOR_TASK_OWNER_DROPPED | 1 {
455 self.inner.terminated.notify_waiters();
456 }
457 }
458}
459
460#[derive(Debug, Clone)]
466pub enum SourcePosition {
467 Initial,
469 Resume {
471 attempt: laminar_core::state::CheckpointAttempt,
473 checkpoint: SourceCheckpoint,
475 },
476}
477
478#[derive(Debug, Clone)]
483pub struct SourceStart {
484 config: ConnectorConfig,
486 position: SourcePosition,
488 delivery: DeliveryGuarantee,
490}
491
492impl SourceStart {
493 pub fn new(
498 config: ConnectorConfig,
499 position: SourcePosition,
500 delivery: DeliveryGuarantee,
501 ) -> Result<Self, ConnectorError> {
502 if matches!(
503 &position,
504 SourcePosition::Resume { attempt, .. } if !attempt.is_canonical()
505 ) {
506 return Err(ConnectorError::ConfigurationError(
507 "source resume must use one nonzero canonical checkpoint ID".into(),
508 ));
509 }
510 Ok(Self {
511 config,
512 position,
513 delivery,
514 })
515 }
516
517 #[must_use]
519 pub fn into_parts(self) -> (ConnectorConfig, SourcePosition, DeliveryGuarantee) {
520 (self.config, self.position, self.delivery)
521 }
522}
523
524#[derive(Debug, Clone, PartialEq, Eq)]
526pub struct SourceDrainRequest {
527 pub round: laminar_core::checkpoint::AssignmentDrainId,
529}
530
531impl SourceDrainRequest {
532 pub fn new(round: laminar_core::checkpoint::AssignmentDrainId) -> Result<Self, ConnectorError> {
537 if !round.is_canonical() {
538 return Err(ConnectorError::ConfigurationError(
539 "source drain round is not canonical".into(),
540 ));
541 }
542 Ok(Self { round })
543 }
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548pub enum SourceDrainOutcome {
549 Commit,
551 Abort,
553}
554
555#[derive(Debug, Clone, Copy, PartialEq, Eq)]
557pub struct SourceDrainResolution {
558 pub round: laminar_core::checkpoint::AssignmentDrainId,
560 pub outcome: SourceDrainOutcome,
562}
563
564#[async_trait]
576pub trait SourceConnector: Send {
577 fn cancellation_policy(&self) -> ConnectorCancellationPolicy {
582 ConnectorCancellationPolicy::RetireConnector
583 }
584
585 fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
591 None
592 }
593
594 async fn start(&mut self, request: SourceStart) -> Result<(), ConnectorError>;
600
601 async fn poll_batch(
616 &mut self,
617 max_records: usize,
618 ) -> Result<Option<SourceBatch>, ConnectorError>;
619
620 async fn discover_schema(
625 &mut self,
626 _properties: &std::collections::HashMap<String, String>,
627 ) -> Result<(), ConnectorError> {
628 Ok(())
629 }
630
631 fn schema(&self) -> SchemaRef;
633
634 fn checkpoint(&self) -> SourceCheckpoint;
637
638 fn try_checkpoint(&self) -> Result<Option<SourceCheckpoint>, ConnectorError> {
646 Ok(Some(self.checkpoint()))
647 }
648
649 fn checkpoint_ready(&self) -> Result<bool, ConnectorError> {
660 Ok(true)
661 }
662
663 fn drive_control_plane(&mut self) {}
667
668 fn begin_drain(
679 &mut self,
680 _request: &SourceDrainRequest,
681 _deadline: tokio::time::Instant,
682 ) -> Result<(), ConnectorError> {
683 Err(ConnectorError::ConfigurationError(
684 "assignment-scoped source does not implement provider drain".into(),
685 ))
686 }
687
688 fn poll_drain_ready(
695 &mut self,
696 _round: laminar_core::checkpoint::AssignmentDrainId,
697 ) -> Result<bool, ConnectorError> {
698 Err(ConnectorError::ConfigurationError(
699 "assignment-scoped source does not expose provider drain readiness".into(),
700 ))
701 }
702
703 async fn finish_drain(
710 &mut self,
711 _resolution: SourceDrainResolution,
712 _deadline: tokio::time::Instant,
713 ) -> Result<(), ConnectorError> {
714 Err(ConnectorError::ConfigurationError(
715 "assignment-scoped source does not implement provider drain resolution".into(),
716 ))
717 }
718
719 fn set_vnode_assignment(
732 &mut self,
733 _source_identity: &str,
734 _registry: Arc<laminar_core::state::VnodeRegistry>,
735 _self_id: laminar_core::state::NodeId,
736 ) -> Result<(), ConnectorError> {
737 Err(ConnectorError::ConfigurationError(
738 "source advertises splittable placement but does not implement vnode assignment".into(),
739 ))
740 }
741
742 async fn close(&mut self) -> Result<(), ConnectorError>;
744
745 fn data_ready_notify(&self) -> Option<Arc<Notify>> {
754 None
755 }
756
757 fn contract(&self, _config: &ConnectorConfig) -> Result<SourceContract, ConnectorError> {
767 Ok(SourceContract::default())
768 }
769
770 fn recovery_identity_options(
783 &self,
784 _config: &ConnectorConfig,
785 ) -> Result<Option<std::collections::BTreeMap<String, String>>, ConnectorError> {
786 Ok(None)
787 }
788
789 async fn notify_epoch_committed(
810 &mut self,
811 _epoch: u64,
812 _checkpoint: &SourceCheckpoint,
813 ) -> Result<(), ConnectorError> {
814 Ok(())
815 }
816}
817
818#[async_trait]
832pub trait SinkConnector: Send {
833 fn cancellation_policy(&self) -> ConnectorCancellationPolicy {
838 ConnectorCancellationPolicy::RetireConnector
839 }
840
841 fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
847 None
848 }
849
850 fn contract(&self, _config: &ConnectorConfig) -> Result<SinkContract, ConnectorError> {
861 Ok(SinkContract::default())
862 }
863
864 async fn open(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError>;
866
867 async fn write_batch(&mut self, batch: &RecordBatch) -> Result<WriteResult, ConnectorError>;
872
873 fn schema(&self) -> SchemaRef;
875
876 async fn begin_epoch(&mut self, _epoch: u64) -> Result<(), ConnectorError> {
879 Ok(())
880 }
881
882 async fn pre_commit(&mut self, _epoch: u64) -> Result<Option<Vec<u8>>, ConnectorError> {
894 if self.as_coordinated_committer().is_some() {
895 return Err(ConnectorError::ConfigurationError(
896 "sink exposes a coordinated committer but does not override pre_commit".into(),
897 ));
898 }
899 self.flush().await?;
900 Ok(None)
901 }
902
903 async fn rollback_epoch(&mut self, _epoch: u64) -> Result<(), ConnectorError> {
907 Ok(())
908 }
909
910 fn suggested_write_timeout(&self) -> std::time::Duration;
913
914 fn flush_interval(&self) -> std::time::Duration {
918 std::time::Duration::from_secs(5)
919 }
920
921 async fn flush(&mut self) -> Result<(), ConnectorError> {
925 Ok(())
926 }
927
928 async fn close(&mut self) -> Result<(), ConnectorError>;
930
931 fn as_coordinated_committer(&self) -> Option<&dyn CoordinatedCommitter> {
934 None
935 }
936}
937
938pub const MAX_COORDINATED_COMMIT_PAYLOAD_BYTES: usize = 16 * 1024 * 1024;
944
945pub const MAX_COORDINATED_COMMIT_BATCH_BYTES: usize = 64 * 1024 * 1024;
947
948pub const MAX_COORDINATED_COMMIT_BATCH_ENTRIES: usize = 4_096;
950
951#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
958pub struct CoordinatedCommitNamespace {
959 pub pipeline_identity: laminar_core::checkpoint::checkpoint_manifest::PipelineIdentity,
961 pub deployment_id: String,
963 pub sink_id: String,
965}
966
967impl CoordinatedCommitNamespace {
968 pub fn try_new(
974 pipeline_identity: laminar_core::checkpoint::checkpoint_manifest::PipelineIdentity,
975 deployment_id: impl Into<String>,
976 sink_id: impl Into<String>,
977 ) -> Result<Self, ConnectorError> {
978 let deployment_id = deployment_id.into();
979 let sink_id = sink_id.into();
980 if pipeline_identity.sha256.len() != 64
981 || !pipeline_identity
982 .sha256
983 .bytes()
984 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
985 {
986 return Err(ConnectorError::ConfigurationError(
987 "coordinated commit requires a canonical lowercase SHA-256 pipeline identity"
988 .into(),
989 ));
990 }
991 if sink_id.is_empty() {
992 return Err(ConnectorError::ConfigurationError(
993 "coordinated commit sink id cannot be empty".into(),
994 ));
995 }
996 let parsed_deployment = uuid::Uuid::parse_str(&deployment_id).map_err(|error| {
997 ConnectorError::ConfigurationError(format!(
998 "coordinated commit deployment id is not a UUID: {error}"
999 ))
1000 })?;
1001 if parsed_deployment.is_nil() || parsed_deployment.to_string() != deployment_id {
1002 return Err(ConnectorError::ConfigurationError(
1003 "coordinated commit deployment id must be a canonical non-nil UUID".into(),
1004 ));
1005 }
1006 Ok(Self {
1007 pipeline_identity,
1008 deployment_id,
1009 sink_id,
1010 })
1011 }
1012
1013 #[must_use]
1015 pub fn external_key(&self) -> String {
1016 let mut digest = Sha256::new();
1017 digest.update(self.pipeline_identity.canonical_version.to_be_bytes());
1018 digest.update(self.pipeline_identity.sha256.as_bytes());
1019 digest.update([0]);
1020 digest.update(self.deployment_id.as_bytes());
1021 digest.update([0]);
1022 digest.update(self.sink_id.as_bytes());
1023 let digest = digest.finalize();
1024 format!("ldb-c3-{digest:x}")
1025 }
1026}
1027
1028#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1030pub struct CoordinatedCommitCursor {
1031 pub checkpoint_id: u64,
1033 pub fencing_token: u64,
1035}
1036
1037#[derive(Debug, Clone, PartialEq, Eq)]
1039pub struct CoordinatedCommitPayload {
1040 pub attempt: laminar_core::state::CheckpointAttempt,
1042 pub participant_id: u64,
1044 pub payload: Option<Vec<u8>>,
1046}
1047
1048#[derive(Debug, Clone, PartialEq, Eq)]
1050pub struct CoordinatedCommitBatch {
1051 pub namespace: CoordinatedCommitNamespace,
1053 pub expected_predecessor: CoordinatedCommitCursor,
1057 pub fencing_token: u64,
1059 pub target: laminar_core::state::CheckpointAttempt,
1061 pub entries: Vec<CoordinatedCommitPayload>,
1063}
1064
1065#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1071pub struct CoordinatedCommitContext {
1072 deadline: tokio::time::Instant,
1073}
1074
1075impl CoordinatedCommitContext {
1076 #[must_use]
1078 pub const fn new(deadline: tokio::time::Instant) -> Self {
1079 Self { deadline }
1080 }
1081
1082 #[must_use]
1084 pub const fn deadline(self) -> tokio::time::Instant {
1085 self.deadline
1086 }
1087
1088 #[must_use]
1090 pub fn remaining(self) -> std::time::Duration {
1091 self.deadline
1092 .saturating_duration_since(tokio::time::Instant::now())
1093 }
1094}
1095
1096impl CoordinatedCommitBatch {
1097 #[must_use]
1101 pub fn exact_fingerprint(&self) -> [u8; 32] {
1102 fn update_length(hasher: &mut Sha256, length: usize) {
1103 let source = length.to_be_bytes();
1104 let mut encoded = [0_u8; 16];
1105 let start = encoded.len() - source.len();
1106 encoded[start..].copy_from_slice(&source);
1107 hasher.update(encoded);
1108 }
1109
1110 fn update_framed(hasher: &mut Sha256, bytes: &[u8]) {
1111 update_length(hasher, bytes.len());
1112 hasher.update(bytes);
1113 }
1114
1115 let mut hasher = Sha256::new();
1116 update_framed(&mut hasher, b"laminardb/coordinated-commit-batch/v1");
1117 update_framed(&mut hasher, self.namespace.external_key().as_bytes());
1118 hasher.update(self.expected_predecessor.checkpoint_id.to_be_bytes());
1119 hasher.update(self.expected_predecessor.fencing_token.to_be_bytes());
1120 hasher.update(self.fencing_token.to_be_bytes());
1121 hasher.update(self.target.epoch.to_be_bytes());
1122 hasher.update(self.target.checkpoint_id.to_be_bytes());
1123 update_length(&mut hasher, self.entries.len());
1124 for entry in &self.entries {
1125 hasher.update(entry.attempt.epoch.to_be_bytes());
1126 hasher.update(entry.attempt.checkpoint_id.to_be_bytes());
1127 hasher.update(entry.participant_id.to_be_bytes());
1128 match &entry.payload {
1129 Some(payload) => {
1130 hasher.update([1]);
1131 update_framed(&mut hasher, payload);
1132 }
1133 None => hasher.update([0]),
1134 }
1135 }
1136 hasher.finalize().into()
1137 }
1138
1139 pub fn validate_shape(&self) -> Result<(), String> {
1145 use laminar_core::state::CheckpointAttemptRelation;
1146
1147 if !self.target.is_canonical() {
1148 return Err(
1149 "coordinated batch target must use one nonzero canonical checkpoint ID".into(),
1150 );
1151 }
1152 if let Some(entry) = self
1153 .entries
1154 .iter()
1155 .find(|entry| !entry.attempt.is_canonical())
1156 {
1157 return Err(format!(
1158 "coordinated batch entry for participant {} must use one nonzero canonical checkpoint ID",
1159 entry.participant_id
1160 ));
1161 }
1162 if self.expected_predecessor.checkpoint_id >= self.target.checkpoint_id {
1163 return Err(format!(
1164 "invalid coordinated batch predecessor {} for target {}",
1165 self.expected_predecessor.checkpoint_id, self.target.checkpoint_id
1166 ));
1167 }
1168 if (self.expected_predecessor.checkpoint_id == 0)
1169 != (self.expected_predecessor.fencing_token == 0)
1170 {
1171 return Err(
1172 "coordinated batch predecessor must be either an exact non-zero cursor or the zero cursor"
1173 .into(),
1174 );
1175 }
1176 if self.fencing_token == 0 {
1177 return Err("coordinated batch fencing token must be non-zero".into());
1178 }
1179 if self.entries.is_empty() || self.entries.len() > MAX_COORDINATED_COMMIT_BATCH_ENTRIES {
1180 return Err(format!(
1181 "coordinated batch entry count must be in 1..={MAX_COORDINATED_COMMIT_BATCH_ENTRIES}"
1182 ));
1183 }
1184
1185 let mut total_payload_bytes = 0usize;
1186 let mut previous: Option<&CoordinatedCommitPayload> = None;
1187 for entry in &self.entries {
1188 if entry.attempt.checkpoint_id <= self.expected_predecessor.checkpoint_id
1189 || entry.attempt.checkpoint_id > self.target.checkpoint_id
1190 {
1191 return Err(
1192 "coordinated batch entries do not cover the predecessor-to-target interval"
1193 .into(),
1194 );
1195 }
1196 if let Some(payload) = &entry.payload {
1197 if payload.len() > MAX_COORDINATED_COMMIT_PAYLOAD_BYTES {
1198 return Err(format!(
1199 "coordinated participant payload exceeds the fixed {MAX_COORDINATED_COMMIT_PAYLOAD_BYTES} byte limit"
1200 ));
1201 }
1202 total_payload_bytes = total_payload_bytes
1203 .checked_add(payload.len())
1204 .ok_or_else(|| "coordinated batch payload byte count overflow".to_owned())?;
1205 if total_payload_bytes > MAX_COORDINATED_COMMIT_BATCH_BYTES {
1206 return Err(format!(
1207 "coordinated batch payloads exceed the fixed {MAX_COORDINATED_COMMIT_BATCH_BYTES} byte limit"
1208 ));
1209 }
1210 }
1211
1212 if let Some(previous) = previous {
1213 match entry.attempt.relation_to(previous.attempt) {
1214 CheckpointAttemptRelation::Exact
1215 if entry.participant_id > previous.participant_id => {}
1216 CheckpointAttemptRelation::Newer => {}
1217 CheckpointAttemptRelation::Exact => {
1218 return Err(
1219 "coordinated batch contains a duplicate or out-of-order attempt/participant key"
1220 .into(),
1221 );
1222 }
1223 CheckpointAttemptRelation::Older | CheckpointAttemptRelation::Conflict => {
1224 return Err(
1225 "coordinated batch attempts are not in coherent epoch/checkpoint order"
1226 .into(),
1227 );
1228 }
1229 }
1230 }
1231 previous = Some(entry);
1232 }
1233 if previous.map(|entry| entry.attempt) != Some(self.target) {
1234 return Err("coordinated batch target is not its final exact attempt".into());
1235 }
1236 Ok(())
1237 }
1238
1239 pub fn validate_observed_cursor(
1248 &self,
1249 observed: Option<CoordinatedCommitCursor>,
1250 ) -> Result<(), String> {
1251 self.validate_shape()?;
1252 let Some(observed) = observed else {
1253 return if self.expected_predecessor.checkpoint_id == 0 {
1254 Ok(())
1255 } else {
1256 Err(format!(
1257 "external cursor is absent below expected predecessor {}",
1258 self.expected_predecessor.checkpoint_id
1259 ))
1260 };
1261 };
1262 if observed.fencing_token == 0 {
1263 return Err("external cursor contains a zero fencing token".into());
1264 }
1265 if observed.fencing_token > self.fencing_token {
1266 return Err(format!(
1267 "external fencing token {} is newer than designated committer token {}",
1268 observed.fencing_token, self.fencing_token
1269 ));
1270 }
1271 if observed.checkpoint_id >= self.target.checkpoint_id
1272 && observed.fencing_token != self.fencing_token
1273 {
1274 return Err(format!(
1275 "external cursor at or above target {} has fencing token {}, expected {}",
1276 self.target.checkpoint_id, observed.fencing_token, self.fencing_token
1277 ));
1278 }
1279 if observed.checkpoint_id < self.expected_predecessor.checkpoint_id {
1280 return Err(format!(
1281 "external cursor rolled back from expected predecessor {} to {}",
1282 self.expected_predecessor.checkpoint_id, observed.checkpoint_id
1283 ));
1284 }
1285 if observed.checkpoint_id == self.expected_predecessor.checkpoint_id
1286 && observed != self.expected_predecessor
1287 {
1288 return Err(format!(
1289 "external cursor checkpoint {} has fencing token {}, expected predecessor token {}",
1290 observed.checkpoint_id,
1291 observed.fencing_token,
1292 self.expected_predecessor.fencing_token
1293 ));
1294 }
1295 if observed.checkpoint_id > self.expected_predecessor.checkpoint_id
1296 && observed.fencing_token < self.expected_predecessor.fencing_token
1297 {
1298 return Err(format!(
1299 "external cursor advanced past predecessor {} while fencing token regressed from {} to {}",
1300 self.expected_predecessor.checkpoint_id,
1301 self.expected_predecessor.fencing_token,
1302 observed.fencing_token
1303 ));
1304 }
1305 if observed.checkpoint_id < self.target.checkpoint_id
1306 && observed.checkpoint_id != self.expected_predecessor.checkpoint_id
1307 && !self
1308 .entries
1309 .iter()
1310 .any(|entry| entry.attempt.checkpoint_id == observed.checkpoint_id)
1311 {
1312 return Err(format!(
1313 "external cursor {} is not an exact attempt in batch {}..={}",
1314 observed.checkpoint_id,
1315 self.expected_predecessor.checkpoint_id,
1316 self.target.checkpoint_id
1317 ));
1318 }
1319 Ok(())
1320 }
1321}
1322
1323#[async_trait]
1330pub trait CoordinatedCommitter: Send + Sync {
1331 async fn commit_aggregated(
1335 &self,
1336 batch: CoordinatedCommitBatch,
1337 context: CoordinatedCommitContext,
1338 ) -> Result<(), ConnectorError>;
1339
1340 async fn committed_cursor(
1344 &self,
1345 namespace: &CoordinatedCommitNamespace,
1346 ) -> Result<Option<CoordinatedCommitCursor>, ConnectorError>;
1347}
1348
1349#[cfg(test)]
1350#[allow(clippy::cast_possible_wrap)]
1351mod tests {
1352 use super::*;
1353 use arrow_array::Int64Array;
1354 use arrow_schema::{DataType, Field, Schema};
1355 use std::sync::Arc;
1356
1357 fn test_schema() -> SchemaRef {
1358 Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]))
1359 }
1360
1361 fn test_batch(n: usize) -> RecordBatch {
1362 #[allow(clippy::cast_possible_wrap)]
1363 let ids: Vec<i64> = (0..n as i64).collect();
1364 RecordBatch::try_new(test_schema(), vec![Arc::new(Int64Array::from(ids))]).unwrap()
1365 }
1366
1367 #[test]
1368 fn connector_task_generation_terminates_after_owner_and_every_guard() {
1369 let (owner, tracker) = ConnectorTaskOwner::new();
1370 let first = owner.track().expect("live generation");
1371 let second = owner.track().expect("live generation");
1372
1373 assert!(!tracker.is_terminated());
1374 drop(owner);
1375 assert!(!tracker.is_terminated());
1376 drop(first);
1377 assert!(!tracker.is_terminated());
1378 drop(second);
1379 assert!(tracker.is_terminated());
1380 }
1381
1382 #[test]
1383 fn connector_task_admission_is_sealed_by_owner_drop() {
1384 let (owner, tracker) = ConnectorTaskOwner::new();
1385 let admission = owner.admission();
1386 let admission_clone = admission.clone();
1387 let admitted = admission.track().expect("live generation");
1388
1389 drop(owner);
1390
1391 assert!(admission.track().is_none());
1392 assert!(admission_clone.track().is_none());
1393 assert!(!tracker.is_terminated());
1394 drop(admitted);
1395 assert!(tracker.is_terminated());
1396 }
1397
1398 #[test]
1399 fn connector_task_admission_does_not_retain_generation_state() {
1400 let (owner, tracker) = ConnectorTaskOwner::new();
1401 let admission = owner.admission();
1402
1403 drop(owner);
1404 drop(tracker);
1405
1406 assert!(admission.inner.upgrade().is_none());
1407 assert!(admission.track().is_none());
1408 }
1409
1410 #[tokio::test]
1411 async fn connector_task_wait_wakes_every_tracker_clone() {
1412 let (owner, tracker) = ConnectorTaskOwner::new();
1413 let guard = owner.track().expect("live generation");
1414 let first = tokio::spawn({
1415 let tracker = tracker.clone();
1416 async move { tracker.wait_terminated().await }
1417 });
1418 let second = tokio::spawn({
1419 let tracker = tracker.clone();
1420 async move { tracker.wait_terminated().await }
1421 });
1422
1423 drop(owner);
1424 assert!(!tracker.is_terminated());
1425 drop(guard);
1426
1427 tokio::time::timeout(std::time::Duration::from_secs(1), async {
1428 first.await.expect("first waiter task");
1429 second.await.expect("second waiter task");
1430 })
1431 .await
1432 .expect("tracker waiters must wake");
1433 tracker.wait_terminated().await;
1434 }
1435
1436 #[test]
1437 fn test_source_batch() {
1438 let batch = SourceBatch::new(test_batch(10));
1439 assert_eq!(batch.num_rows(), 10);
1440 }
1441
1442 #[test]
1443 fn test_write_result() {
1444 let result = WriteResult::new(100, 5000);
1445 assert_eq!(result.records_written, 100);
1446 assert_eq!(result.bytes_written, 5000);
1447 }
1448
1449 #[test]
1450 fn source_drain_request_requires_canonical_round() {
1451 let round = laminar_core::checkpoint::AssignmentDrainId {
1452 predecessor_version: 7,
1453 target_version: 8,
1454 digest: [9; 32],
1455 };
1456 assert_eq!(SourceDrainRequest::new(round).unwrap().round, round);
1457 assert!(
1458 SourceDrainRequest::new(laminar_core::checkpoint::AssignmentDrainId {
1459 predecessor_version: 8,
1460 target_version: 8,
1461 digest: [9; 32],
1462 })
1463 .is_err()
1464 );
1465 }
1466
1467 #[test]
1468 fn source_contract_defaults_fail_closed() {
1469 let contract = SourceContract::default();
1470 assert_eq!(contract.consistency, SourceConsistency::Ephemeral);
1471 assert_eq!(contract.topology, SourceTopology::Singleton);
1472 assert!(!contract.supports_replay());
1473 assert!(!contract.requires_checkpointing());
1474 assert!(!contract.is_exact_delivery_certified());
1475 }
1476
1477 #[test]
1478 fn commit_coupled_sources_are_replayable_and_require_checkpoints() {
1479 let contract = SourceContract::new(
1480 SourceConsistency::CommitCoupled,
1481 SourceTopology::NodeLocalIngress,
1482 );
1483 assert!(contract.supports_replay());
1484 assert!(contract.requires_checkpointing());
1485 }
1486
1487 #[test]
1488 fn source_start_rejects_split_and_zero_resume_before_connector_start() {
1489 use laminar_core::state::CheckpointAttempt;
1490
1491 for attempt in [CheckpointAttempt::new(7, 8), CheckpointAttempt::new(0, 0)] {
1492 let error = SourceStart::new(
1493 ConnectorConfig::new("test"),
1494 SourcePosition::Resume {
1495 attempt,
1496 checkpoint: SourceCheckpoint::new(),
1497 },
1498 DeliveryGuarantee::AtLeastOnce,
1499 )
1500 .unwrap_err();
1501 assert!(matches!(
1502 error,
1503 ConnectorError::ConfigurationError(message)
1504 if message.contains("one nonzero canonical checkpoint ID")
1505 ));
1506 }
1507 }
1508
1509 #[test]
1510 fn source_start_accepts_initial_and_exposes_validated_parts() {
1511 let mut config = ConnectorConfig::new("test");
1512 config.set("endpoint", "local");
1513 let request = SourceStart::new(
1514 config,
1515 SourcePosition::Initial,
1516 DeliveryGuarantee::BestEffort,
1517 )
1518 .unwrap();
1519
1520 let (config, position, delivery) = request.into_parts();
1521 assert_eq!(config.get("endpoint"), Some("local"));
1522 assert!(matches!(position, SourcePosition::Initial));
1523 assert_eq!(delivery, DeliveryGuarantee::BestEffort);
1524 }
1525
1526 #[test]
1527 fn sink_contract_defaults_fail_closed() {
1528 let contract = SinkContract::default();
1529 assert_eq!(contract.consistency, SinkConsistency::Ephemeral);
1530 assert_eq!(contract.topology, SinkTopology::Singleton);
1531 assert_eq!(contract.input_mode, SinkInputMode::AppendOnly);
1532 assert!(!contract.input_mode.accepts_full_changelog());
1533 }
1534
1535 #[test]
1536 fn coordinated_namespace_is_bounded_stable_and_sink_scoped() {
1537 use laminar_core::storage::checkpoint_manifest::PipelineIdentity;
1538 const DEPLOYMENT: &str = "018f0000-0000-7000-8000-000000000001";
1539
1540 let first =
1541 CoordinatedCommitNamespace::try_new(PipelineIdentity::empty(), DEPLOYMENT, "orders")
1542 .unwrap();
1543 let same =
1544 CoordinatedCommitNamespace::try_new(PipelineIdentity::empty(), DEPLOYMENT, "orders")
1545 .unwrap();
1546 let other =
1547 CoordinatedCommitNamespace::try_new(PipelineIdentity::empty(), DEPLOYMENT, "audit")
1548 .unwrap();
1549 let other_deployment = CoordinatedCommitNamespace::try_new(
1550 PipelineIdentity::empty(),
1551 "018f0000-0000-7000-8000-000000000002",
1552 "orders",
1553 )
1554 .unwrap();
1555
1556 assert_eq!(first.external_key(), same.external_key());
1557 assert_ne!(first.external_key(), other.external_key());
1558 assert_ne!(first.external_key(), other_deployment.external_key());
1559 assert_eq!(first.external_key().len(), "ldb-c3-".len() + 64);
1560 assert!(first
1561 .external_key()
1562 .bytes()
1563 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'));
1564 }
1565
1566 #[test]
1567 fn coordinated_namespace_rejects_ambiguous_identity() {
1568 const DEPLOYMENT: &str = "018f0000-0000-7000-8000-000000000001";
1569
1570 use laminar_core::storage::checkpoint_manifest::{
1571 PipelineIdentity, PIPELINE_IDENTITY_VERSION,
1572 };
1573 let malformed = PipelineIdentity {
1574 canonical_version: PIPELINE_IDENTITY_VERSION,
1575 sha256: "NOT-A-DIGEST".into(),
1576 };
1577 assert!(CoordinatedCommitNamespace::try_new(malformed, DEPLOYMENT, "orders").is_err());
1578 assert!(
1579 CoordinatedCommitNamespace::try_new(PipelineIdentity::empty(), DEPLOYMENT, "").is_err()
1580 );
1581 assert!(CoordinatedCommitNamespace::try_new(
1582 PipelineIdentity::empty(),
1583 "not-a-uuid",
1584 "orders"
1585 )
1586 .is_err());
1587 }
1588
1589 #[test]
1590 fn coordinated_batch_fingerprint_covers_the_exact_ordered_cut() {
1591 use laminar_core::state::CheckpointAttempt;
1592 use laminar_core::storage::checkpoint_manifest::PipelineIdentity;
1593
1594 let namespace = CoordinatedCommitNamespace::try_new(
1595 PipelineIdentity::empty(),
1596 "018f0000-0000-7000-8000-000000000001",
1597 "orders",
1598 )
1599 .unwrap();
1600 let attempt = CheckpointAttempt::new(8, 108);
1601 let batch = CoordinatedCommitBatch {
1602 namespace,
1603 expected_predecessor: CoordinatedCommitCursor {
1604 checkpoint_id: 107,
1605 fencing_token: 3,
1606 },
1607 fencing_token: 4,
1608 target: attempt,
1609 entries: vec![CoordinatedCommitPayload {
1610 attempt,
1611 participant_id: 7,
1612 payload: None,
1613 }],
1614 };
1615 let expected = batch.exact_fingerprint();
1616 assert_eq!(expected, batch.clone().exact_fingerprint());
1617
1618 let mut variants = Vec::new();
1619 let mut variant = batch.clone();
1620 variant.namespace = CoordinatedCommitNamespace::try_new(
1621 PipelineIdentity::empty(),
1622 "018f0000-0000-7000-8000-000000000001",
1623 "audit",
1624 )
1625 .unwrap();
1626 variants.push(variant);
1627 let mut variant = batch.clone();
1628 variant.expected_predecessor.checkpoint_id -= 1;
1629 variants.push(variant);
1630 let mut variant = batch.clone();
1631 variant.fencing_token += 1;
1632 variants.push(variant);
1633 let mut variant = batch.clone();
1634 variant.target.epoch += 1;
1635 variants.push(variant);
1636 let mut variant = batch.clone();
1637 variant.entries[0].attempt.checkpoint_id += 1;
1638 variants.push(variant);
1639 let mut variant = batch.clone();
1640 variant.entries[0].participant_id += 1;
1641 variants.push(variant);
1642 let mut variant = batch;
1643 variant.entries[0].payload = Some(Vec::new());
1644 variants.push(variant);
1645
1646 assert!(variants
1647 .into_iter()
1648 .all(|variant| variant.exact_fingerprint() != expected));
1649 }
1650
1651 fn valid_coordinated_batch() -> CoordinatedCommitBatch {
1652 use laminar_core::state::CheckpointAttempt;
1653 use laminar_core::storage::checkpoint_manifest::PipelineIdentity;
1654
1655 let target = CheckpointAttempt::canonical(102);
1656 CoordinatedCommitBatch {
1657 namespace: CoordinatedCommitNamespace::try_new(
1658 PipelineIdentity::empty(),
1659 "018f0000-0000-7000-8000-000000000001",
1660 "orders",
1661 )
1662 .unwrap(),
1663 expected_predecessor: CoordinatedCommitCursor {
1664 checkpoint_id: 101,
1665 fencing_token: 1,
1666 },
1667 fencing_token: 2,
1668 target,
1669 entries: vec![CoordinatedCommitPayload {
1670 attempt: target,
1671 participant_id: 0,
1672 payload: None,
1673 }],
1674 }
1675 }
1676
1677 #[test]
1678 fn coordinated_batch_rejects_noncanonical_target_before_other_shape_checks() {
1679 use laminar_core::state::CheckpointAttempt;
1680
1681 for target in [
1682 CheckpointAttempt::new(102, 103),
1683 CheckpointAttempt::new(0, 0),
1684 ] {
1685 let mut batch = valid_coordinated_batch();
1686 batch.target = target;
1687 let error = batch.validate_shape().unwrap_err();
1688 assert!(
1689 error.contains("target must use one nonzero canonical checkpoint ID"),
1690 "unexpected validation error: {error}"
1691 );
1692 }
1693 }
1694
1695 #[test]
1696 fn coordinated_batch_rejects_noncanonical_entry_before_other_shape_checks() {
1697 use laminar_core::state::CheckpointAttempt;
1698
1699 for attempt in [
1700 CheckpointAttempt::new(101, 102),
1701 CheckpointAttempt::new(0, 0),
1702 ] {
1703 let mut batch = valid_coordinated_batch();
1704 batch.entries[0].attempt = attempt;
1705 let error = batch.validate_shape().unwrap_err();
1706 assert!(
1707 error.contains(
1708 "entry for participant 0 must use one nonzero canonical checkpoint ID"
1709 ),
1710 "unexpected validation error: {error}"
1711 );
1712 }
1713 }
1714
1715 #[test]
1716 fn coordinated_batch_rejects_cursor_rollback_and_unproven_overlap() {
1717 use laminar_core::state::CheckpointAttempt;
1718 use laminar_core::storage::checkpoint_manifest::PipelineIdentity;
1719
1720 let first = CheckpointAttempt::canonical(108);
1721 let target = CheckpointAttempt::canonical(110);
1722 let batch = CoordinatedCommitBatch {
1723 namespace: CoordinatedCommitNamespace::try_new(
1724 PipelineIdentity::empty(),
1725 "018f0000-0000-7000-8000-000000000001",
1726 "orders",
1727 )
1728 .unwrap(),
1729 expected_predecessor: CoordinatedCommitCursor {
1730 checkpoint_id: 107,
1731 fencing_token: 3,
1732 },
1733 fencing_token: 4,
1734 target,
1735 entries: vec![
1736 CoordinatedCommitPayload {
1737 attempt: first,
1738 participant_id: 0,
1739 payload: None,
1740 },
1741 CoordinatedCommitPayload {
1742 attempt: target,
1743 participant_id: 0,
1744 payload: None,
1745 },
1746 ],
1747 };
1748
1749 let cursor = |checkpoint_id, fencing_token| {
1750 Some(CoordinatedCommitCursor {
1751 checkpoint_id,
1752 fencing_token,
1753 })
1754 };
1755 assert!(batch.validate_observed_cursor(cursor(106, 3)).is_err());
1756 assert!(batch.validate_observed_cursor(cursor(109, 3)).is_err());
1757 assert!(batch.validate_observed_cursor(cursor(107, 2)).is_err());
1758 assert!(batch.validate_observed_cursor(cursor(107, 3)).is_ok());
1759 assert!(batch.validate_observed_cursor(cursor(108, 3)).is_ok());
1760 assert!(batch.validate_observed_cursor(cursor(110, 4)).is_ok());
1761 assert!(batch.validate_observed_cursor(cursor(110, 3)).is_err());
1762 assert!(batch.validate_observed_cursor(cursor(108, 5)).is_err());
1763 }
1764
1765 #[test]
1766 fn coordinated_batch_requires_unique_canonical_attempt_participants() {
1767 use laminar_core::state::CheckpointAttempt;
1768 use laminar_core::storage::checkpoint_manifest::PipelineIdentity;
1769
1770 let namespace = CoordinatedCommitNamespace::try_new(
1771 PipelineIdentity::empty(),
1772 "018f0000-0000-7000-8000-000000000001",
1773 "orders",
1774 )
1775 .unwrap();
1776 let target = CheckpointAttempt::canonical(102);
1777 let batch = |entries| CoordinatedCommitBatch {
1778 namespace: namespace.clone(),
1779 expected_predecessor: CoordinatedCommitCursor {
1780 checkpoint_id: 100,
1781 fencing_token: 1,
1782 },
1783 fencing_token: 2,
1784 target,
1785 entries,
1786 };
1787 let payload = |attempt, participant_id| CoordinatedCommitPayload {
1788 attempt,
1789 participant_id,
1790 payload: None,
1791 };
1792
1793 let duplicate = batch(vec![payload(target, 0), payload(target, 0)]);
1794 assert!(duplicate
1795 .validate_shape()
1796 .unwrap_err()
1797 .contains("duplicate"));
1798
1799 let out_of_order = batch(vec![payload(target, 1), payload(target, 0)]);
1800 assert!(out_of_order
1801 .validate_shape()
1802 .unwrap_err()
1803 .contains("out-of-order"));
1804
1805 let noncanonical = batch(vec![
1806 payload(CheckpointAttempt::new(3, 101), 0),
1807 payload(target, 0),
1808 ]);
1809 assert!(noncanonical
1810 .validate_shape()
1811 .unwrap_err()
1812 .contains("one nonzero canonical checkpoint ID"));
1813 }
1814
1815 #[test]
1816 fn coordinated_batch_entry_limit_accepts_max_and_rejects_max_plus_one() {
1817 use laminar_core::state::CheckpointAttempt;
1818 use laminar_core::storage::checkpoint_manifest::PipelineIdentity;
1819
1820 let namespace = CoordinatedCommitNamespace::try_new(
1821 PipelineIdentity::empty(),
1822 "018f0000-0000-7000-8000-000000000001",
1823 "orders",
1824 )
1825 .unwrap();
1826 let target = CheckpointAttempt::canonical(101);
1827 let make_batch = |count: usize| CoordinatedCommitBatch {
1828 namespace: namespace.clone(),
1829 expected_predecessor: CoordinatedCommitCursor {
1830 checkpoint_id: 0,
1831 fencing_token: 0,
1832 },
1833 fencing_token: 1,
1834 target,
1835 entries: (0..count)
1836 .map(|participant_id| CoordinatedCommitPayload {
1837 attempt: target,
1838 participant_id: participant_id as u64,
1839 payload: None,
1840 })
1841 .collect(),
1842 };
1843
1844 assert!(make_batch(MAX_COORDINATED_COMMIT_BATCH_ENTRIES - 1)
1845 .validate_shape()
1846 .is_ok());
1847 assert!(make_batch(MAX_COORDINATED_COMMIT_BATCH_ENTRIES)
1848 .validate_shape()
1849 .is_ok());
1850 assert!(make_batch(MAX_COORDINATED_COMMIT_BATCH_ENTRIES + 1)
1851 .validate_shape()
1852 .is_err());
1853 }
1854
1855 struct DefaultPreCommitSink {
1856 coordinated: bool,
1857 }
1858
1859 #[async_trait]
1860 impl SinkConnector for DefaultPreCommitSink {
1861 async fn open(&mut self, _config: &ConnectorConfig) -> Result<(), ConnectorError> {
1862 Ok(())
1863 }
1864 async fn write_batch(
1865 &mut self,
1866 _batch: &RecordBatch,
1867 ) -> Result<WriteResult, ConnectorError> {
1868 Ok(WriteResult::new(0, 0))
1869 }
1870 fn schema(&self) -> SchemaRef {
1871 test_schema()
1872 }
1873 fn suggested_write_timeout(&self) -> std::time::Duration {
1874 std::time::Duration::from_secs(5)
1875 }
1876 fn as_coordinated_committer(&self) -> Option<&dyn CoordinatedCommitter> {
1877 self.coordinated
1878 .then_some(self as &dyn CoordinatedCommitter)
1879 }
1880 async fn close(&mut self) -> Result<(), ConnectorError> {
1881 Ok(())
1882 }
1883 }
1884
1885 #[async_trait]
1886 impl CoordinatedCommitter for DefaultPreCommitSink {
1887 async fn commit_aggregated(
1888 &self,
1889 _batch: CoordinatedCommitBatch,
1890 _context: CoordinatedCommitContext,
1891 ) -> Result<(), ConnectorError> {
1892 Ok(())
1893 }
1894
1895 async fn committed_cursor(
1896 &self,
1897 _namespace: &CoordinatedCommitNamespace,
1898 ) -> Result<Option<CoordinatedCommitCursor>, ConnectorError> {
1899 Ok(None)
1900 }
1901 }
1902
1903 #[tokio::test]
1904 async fn default_pre_commit_rejects_coordinated_sink() {
1905 let mut sink = DefaultPreCommitSink { coordinated: true };
1906 assert!(matches!(
1907 sink.pre_commit(1).await,
1908 Err(ConnectorError::ConfigurationError(_))
1909 ));
1910 }
1911
1912 #[tokio::test]
1913 async fn default_pre_commit_ok_for_non_coordinated_sink() {
1914 let mut sink = DefaultPreCommitSink { coordinated: false };
1915 assert!(matches!(sink.pre_commit(1).await, Ok(None)));
1916 }
1917}