laminar_connectors/kafka/source/
lifecycle.rs1use super::{
4 async_trait, info, join_background_task, kafka_output_schema, reap_last_arc_off_runtime,
5 resolve_value_subject, warn, Arc, CommitMode, ConnectorConfig, ConnectorError, ConnectorState,
6 ConnectorTaskTracker, Consumer, Format, KafkaReaderDrainCommand, KafkaSource,
7 KafkaSourceConfig, KafkaSourceDrain, Notify, OffsetTracker, Ordering, SchemaRef, SourceBatch,
8 SourceCheckpoint, SourceConnector, SourceConsistency, SourceContract, SourceDrainRequest,
9 SourceDrainResolution, SourceInputMode, SourceRowPositionCapability, SourceStart,
10 SourceTopology, TopicSubscription, KAFKA_BACKGROUND_CLOSE_BUDGET,
11};
12
13#[async_trait]
14impl SourceConnector for KafkaSource {
15 fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
16 Some(self.task_tracker.clone())
17 }
18
19 fn contract(&self, config: &ConnectorConfig) -> Result<SourceContract, ConnectorError> {
20 let format = if config.properties().is_empty() {
21 self.config.validate()?;
22 self.config.format
23 } else {
24 KafkaSourceConfig::from_config(config)?.format
25 };
26 let input_mode = if format == Format::Debezium {
27 SourceInputMode::KeyedUpsert
28 } else {
29 SourceInputMode::AppendOnly
30 };
31 Ok(SourceContract::new(
32 SourceConsistency::Replayable,
33 SourceTopology::Splittable,
34 input_mode,
35 )
36 .with_row_positions(SourceRowPositionCapability::OrderedDeterministic)
37 .with_exact_delivery_certification())
38 }
39
40 fn set_vnode_assignment(
41 &mut self,
42 source_identity: &str,
43 registry: Arc<laminar_core::state::VnodeRegistry>,
44 self_id: laminar_core::state::NodeId,
45 ) -> Result<(), ConnectorError> {
46 if source_identity.is_empty() {
47 return Err(ConnectorError::ConfigurationError(
48 "Kafka vnode assignment requires a non-empty canonical source identity".into(),
49 ));
50 }
51 if self_id.is_unassigned() {
52 return Err(ConnectorError::ConfigurationError(
53 "Kafka vnode assignment requires a nonzero node identity".into(),
54 ));
55 }
56 info!(
57 source = source_identity,
58 self_id = self_id.0,
59 vnode_count = registry.vnode_count(),
60 "Kafka source: engine-controlled partition-to-vnode assignment enabled"
61 );
62 self.source_name = Arc::from(source_identity);
63 self.vnode_assignment = Some((registry, self_id));
64 Ok(())
65 }
66
67 fn begin_drain(
68 &mut self,
69 request: &SourceDrainRequest,
70 deadline: tokio::time::Instant,
71 ) -> Result<(), ConnectorError> {
72 self.check_reader_health("starting a global source drain")?;
73 if let Some(active) = self.source_drain.as_ref() {
74 if active.request != *request {
75 return Err(ConnectorError::InvalidState {
76 expected: format!("active Kafka drain {:?}", active.request.round),
77 actual: format!("conflicting Kafka drain {:?}", request.round),
78 });
79 }
80 return Ok(());
83 }
84 if tokio::time::Instant::now() >= deadline {
85 return Err(ConnectorError::Internal(
86 "Kafka source drain began after its engine deadline".into(),
87 ));
88 }
89 let Some((registry, _)) = self.vnode_assignment.as_ref() else {
90 return Err(ConnectorError::InvalidState {
91 expected: "Kafka cluster assignment installed before source drain".into(),
92 actual: "embedded/single Kafka source".into(),
93 });
94 };
95 if registry.assignment_version() != request.round.predecessor_version {
96 return Err(ConnectorError::InvalidState {
97 expected: format!(
98 "Kafka predecessor assignment {}",
99 request.round.predecessor_version
100 ),
101 actual: registry.assignment_version().to_string(),
102 });
103 }
104 let reconciled = self.reconciled_assignment_version.load(Ordering::Acquire);
105 if reconciled != request.round.predecessor_version {
106 return Err(ConnectorError::InvalidState {
107 expected: format!(
108 "reconciled Kafka predecessor assignment {}",
109 request.round.predecessor_version
110 ),
111 actual: reconciled.to_string(),
112 });
113 }
114 self.ensure_reader_started();
115 let tx = self.reader_drain_tx.as_ref().ok_or_else(|| {
116 ConnectorError::Internal("Kafka cluster reader has no drain control channel".into())
117 })?;
118 tx.send(KafkaReaderDrainCommand::Begin {
119 request: request.clone(),
120 deadline,
121 })
122 .map_err(|_| ConnectorError::Internal("Kafka reader drain channel closed".into()))?;
123 self.source_drain = Some(KafkaSourceDrain {
124 request: request.clone(),
125 prepare_deadline: deadline,
126 boundary: None,
127 cut: None,
128 pending_resolution: None,
129 });
130 self.data_ready.notify_one();
131 Ok(())
132 }
133
134 fn poll_drain_ready(
135 &mut self,
136 round: laminar_core::checkpoint::AssignmentDrainId,
137 ) -> Result<bool, ConnectorError> {
138 self.check_reader_health("capturing a global source drain cut")?;
139 let Some(active) = self.source_drain.as_ref() else {
140 return Err(ConnectorError::InvalidState {
141 expected: format!("active Kafka drain {round:?}"),
142 actual: "no Kafka drain".into(),
143 });
144 };
145 if active.request.round != round {
146 return Err(ConnectorError::InvalidState {
147 expected: format!("active Kafka drain {:?}", active.request.round),
148 actual: format!("cut requested for {round:?}"),
149 });
150 }
151 if active.cut.is_some() {
152 return Ok(true);
153 }
154 if tokio::time::Instant::now() >= active.prepare_deadline {
155 return Err(ConnectorError::Internal(
156 "Kafka drain deadline expired before cursor capture".into(),
157 ));
158 }
159 let Some(boundary) = active.boundary.clone() else {
160 return Ok(false);
161 };
162 let cut = self.capture_drain_positions(&boundary.inputs, Some(active.prepare_deadline))?;
163 let active = self.source_drain.as_mut().expect("checked above");
164 active.cut = Some(cut);
165 Ok(true)
166 }
167
168 async fn finish_drain(
169 &mut self,
170 resolution: SourceDrainResolution,
171 deadline: tokio::time::Instant,
172 ) -> Result<(), ConnectorError> {
173 self.finish_drain_inner(resolution, deadline).await
174 }
175
176 async fn start(&mut self, request: SourceStart) -> Result<(), ConnectorError> {
177 self.start_inner(request).await
178 }
179 async fn discover_schema(
180 &mut self,
181 properties: &std::collections::HashMap<String, String>,
182 ) -> Result<(), ConnectorError> {
183 let cfg = crate::config::ConnectorConfig::with_properties("kafka", properties.clone());
184 let kafka_config = KafkaSourceConfig::from_config(&cfg)?;
185 if kafka_config.format != Format::Avro {
186 return Ok(());
187 }
188
189 let topic = match &kafka_config.subscription {
190 TopicSubscription::Topics(topics) => match topics.first() {
191 Some(t) => {
192 if topics.len() > 1 {
193 warn!(topics = ?topics, chosen = %t,
194 "multi-topic source: using first topic's SR schema");
195 }
196 t.clone()
197 }
198 None => return Ok(()),
199 },
200 TopicSubscription::Pattern(pattern) => {
201 return Err(ConnectorError::ConfigurationError(format!(
202 "topic.pattern '{pattern}' cannot auto-discover a schema; \
203 declare columns explicitly"
204 )));
205 }
206 };
207
208 let Some(sr_client) = Self::build_sr_client(&kafka_config)? else {
209 return Ok(());
210 };
211
212 let subject = resolve_value_subject(
213 kafka_config.schema_registry_subject_strategy,
214 kafka_config.schema_registry_record_name.as_deref(),
215 &topic,
216 );
217 let timeout = kafka_config.schema_registry_discovery_timeout;
218
219 match tokio::time::timeout(timeout, sr_client.get_latest_schema(&subject)).await {
220 Ok(Ok(cached)) => {
221 self.metrics.record_sr_discovery_success();
222 info!(%subject, schema_id = cached.id,
223 fields = cached.arrow_schema.fields().len(),
224 "discovered Avro schema from Schema Registry");
225 self.schema = cached.arrow_schema;
226 Ok(())
227 }
228 Ok(Err(e)) => {
229 self.metrics.record_sr_discovery_failure();
230 Err(e)
231 }
232 Err(_) => {
233 self.metrics.record_sr_discovery_timeout();
234 Err(ConnectorError::Timeout(
235 u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX),
236 ))
237 }
238 }
239 }
240
241 async fn poll_batch(
242 &mut self,
243 max_records: usize,
244 ) -> Result<Option<SourceBatch>, ConnectorError> {
245 self.poll_batch_inner(max_records).await
246 }
247
248 fn schema(&self) -> SchemaRef {
249 kafka_output_schema(
250 &self.schema,
251 self.config.include_metadata,
252 self.config.include_headers,
253 )
254 }
255
256 fn checkpoint_ready(&self) -> Result<bool, ConnectorError> {
257 self.check_reader_health("reconciling source ownership")?;
258 Ok(self.vnode_assignment.as_ref().is_none_or(|(registry, _)| {
259 let version = registry.assignment_version();
260 version != 0 && self.reconciled_assignment_version.load(Ordering::Acquire) == version
261 }))
262 }
263
264 fn drive_control_plane(&mut self) {
265 self.ensure_reader_started();
266 }
267
268 fn checkpoint(&self) -> SourceCheckpoint {
269 self.try_capture_checkpoint()
270 .ok()
271 .flatten()
272 .unwrap_or_default()
273 }
274
275 fn try_checkpoint(&self) -> Result<Option<SourceCheckpoint>, ConnectorError> {
276 self.validate_active_drain_cursor()?;
277 self.try_capture_checkpoint()
278 }
279
280 fn data_ready_notify(&self) -> Option<Arc<Notify>> {
281 Some(Arc::clone(&self.data_ready))
282 }
283
284 async fn notify_epoch_committed(
285 &mut self,
286 epoch: u64,
287 checkpoint: &SourceCheckpoint,
288 ) -> Result<(), ConnectorError> {
289 if !self.config.broker_commit_on_checkpoint || checkpoint.is_empty() {
290 return Ok(());
291 }
292 let tpl = OffsetTracker::try_from_checkpoint(checkpoint)?.to_topic_partition_list();
293 if tpl.count() == 0 {
294 return Ok(());
295 }
296 let Some(consumer) = self.consumer.as_ref() else {
297 self.metrics.commit_failures.inc();
301 warn!(
302 epoch,
303 "Kafka progress commit skipped because the consumer is absent"
304 );
305 return Ok(());
306 };
307
308 if let Err(error) = consumer.commit(&tpl, CommitMode::Async) {
312 self.metrics.commit_failures.inc();
313 warn!(epoch, %error, "Kafka progress commit was not accepted for enqueue");
314 }
315 Ok(())
316 }
317
318 async fn close(&mut self) -> Result<(), ConnectorError> {
319 info!("closing Kafka source connector");
320
321 if let Some(tx) = self.reader_shutdown.take() {
324 let _ = tx.send(true);
325 }
326 if let Some(ref consumer) = self.consumer {
329 consumer.unsubscribe();
330 }
331 let deadline = tokio::time::Instant::now() + KAFKA_BACKGROUND_CLOSE_BUDGET;
332 join_background_task(&mut self.reader_handle, deadline, "reader").await;
333 self.msg_rx = None;
334 self.reader_drain_tx = None;
335 self.source_drain = None;
336 self.channel_len.store(0, Ordering::Release);
337 if let Some(consumer) = self.consumer.take() {
338 reap_last_arc_off_runtime(&self.blocking_tasks, consumer, deadline, "consumer").await;
339 }
340 if !self.blocking_tasks.join_until(deadline).await {
341 self.blocking_tasks.ensure_reaper();
342 }
343 self.state = ConnectorState::Closed;
344 info!("Kafka source connector closed");
345 Ok(())
346 }
347}