fraiseql_core/runtime/subscription/kafka.rs
1use async_trait::async_trait;
2use serde::Serialize;
3
4use super::{SubscriptionError, transport::TransportAdapter, types::SubscriptionEvent};
5
6/// Kafka transport adapter configuration.
7#[derive(Debug, Clone)]
8pub struct KafkaConfig {
9 /// The broker endpoint, **scheme first**: `kafka+ssl://host:port`,
10 /// `kafka+sasl-ssl://host:port`, or `kafka://host:port` for a development broker
11 /// under `FRAISEQL_KAFKA_ALLOW_PLAINTEXT`.
12 ///
13 /// A bare `bootstrap.servers` list is refused rather than defaulted (#1102). This
14 /// adapter ships entity after-images *and* pre-images, and librdkafka reads a
15 /// scheme-less list as `PLAINTEXT` — which is what it did here, with no
16 /// `security.protocol` set at all, until the endpoint was screened by
17 /// [`fraiseql_guard::kafka`].
18 pub endpoint: String,
19
20 /// Default topic for events (can be overridden per subscription).
21 pub default_topic: String,
22
23 /// Client ID for Kafka producer.
24 pub client_id: String,
25
26 /// Message acknowledgment mode ("all", "1", "0").
27 pub acks: String,
28
29 /// Message timeout in milliseconds.
30 pub timeout_ms: u64,
31
32 /// Enable message compression.
33 pub compression: Option<String>,
34}
35
36impl KafkaConfig {
37 /// Create a new Kafka configuration.
38 ///
39 /// `endpoint` must carry a scheme — see [`KafkaConfig::endpoint`]. It is not
40 /// validated here: the refusal happens at [`KafkaAdapter::new`], where it can be
41 /// reported to whoever is mounting the transport.
42 ///
43 /// The default `timeout_ms` is 5 s rather than the CDC sink's 30 s. Subscription
44 /// delivery is at-most-once with no outbox behind it, so a delivery attempt is a
45 /// hot-path task with nothing to retry it — parking one for half a minute over a
46 /// message nobody will resend is the wrong trade.
47 #[must_use]
48 pub fn new(endpoint: impl Into<String>, default_topic: impl Into<String>) -> Self {
49 Self {
50 endpoint: endpoint.into(),
51 default_topic: default_topic.into(),
52 client_id: "fraiseql".to_string(),
53 acks: "all".to_string(),
54 timeout_ms: 5_000,
55 compression: None,
56 }
57 }
58
59 /// Set the client ID.
60 #[must_use]
61 pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
62 self.client_id = client_id.into();
63 self
64 }
65
66 /// Set acknowledgment mode.
67 #[must_use]
68 pub fn with_acks(mut self, acks: impl Into<String>) -> Self {
69 self.acks = acks.into();
70 self
71 }
72
73 /// Set message timeout.
74 #[must_use]
75 pub const fn with_timeout(mut self, timeout_ms: u64) -> Self {
76 self.timeout_ms = timeout_ms;
77 self
78 }
79
80 /// Enable compression (e.g., "gzip", "snappy", "lz4").
81 #[must_use]
82 pub fn with_compression(mut self, compression: impl Into<String>) -> Self {
83 self.compression = Some(compression.into());
84 self
85 }
86}
87
88/// Kafka message format for event delivery.
89#[derive(Debug, Clone, Serialize)]
90pub struct KafkaMessage {
91 /// Unique event identifier.
92 pub event_id: String,
93
94 /// Subscription name.
95 pub subscription_name: String,
96
97 /// Entity type.
98 pub entity_type: String,
99
100 /// Entity primary key (used as message key).
101 pub entity_id: String,
102
103 /// Operation type.
104 pub operation: String,
105
106 /// Event data.
107 pub data: serde_json::Value,
108
109 /// Previous data (for UPDATE operations).
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub old_data: Option<serde_json::Value>,
112
113 /// Event timestamp.
114 pub timestamp: String,
115
116 /// Sequence number.
117 pub sequence_number: u64,
118}
119
120impl KafkaMessage {
121 /// Create a Kafka message from a subscription event.
122 #[must_use]
123 pub fn from_event(event: &SubscriptionEvent, subscription_name: &str) -> Self {
124 Self {
125 event_id: event.event_id.clone(),
126 subscription_name: subscription_name.to_string(),
127 entity_type: event.entity_type.clone(),
128 entity_id: event.entity_id.clone(),
129 operation: format!("{:?}", event.operation),
130 data: event.data.clone(),
131 old_data: event.old_data.clone(),
132 timestamp: event.timestamp.to_rfc3339(),
133 sequence_number: event.sequence_number,
134 }
135 }
136
137 /// Get the message key (`entity_id` for partitioning).
138 #[must_use]
139 pub fn key(&self) -> &str {
140 &self.entity_id
141 }
142}
143
144// =============================================================================
145// Kafka Adapter - Full Implementation (with `kafka` feature)
146// =============================================================================
147
148/// Kafka transport adapter for event streaming.
149///
150/// Delivers subscription events to Apache Kafka topics.
151/// Uses the `entity_id` as the message key for consistent partitioning.
152///
153/// # Feature Flag
154///
155/// This adapter has two implementations:
156/// - **With `kafka` feature**: delivery through [`fraiseql_kafka::KafkaEgress`], the one producer
157/// this workspace builds
158/// - **Without `kafka` feature**: a stub that fails loud on every delivery (#784)
159///
160/// # Example
161///
162/// ```ignore
163/// use fraiseql_core::runtime::subscription::{KafkaAdapter, KafkaConfig};
164///
165/// let config = KafkaConfig::new("kafka+ssl://localhost:9093", "fraiseql-events")
166/// .with_client_id("my-service")
167/// .with_compression("lz4");
168///
169/// let adapter = KafkaAdapter::new(config)?;
170/// adapter.deliver(&event, "orderCreated").await?;
171/// ```
172#[cfg(feature = "kafka")]
173pub struct KafkaAdapter {
174 config: KafkaConfig,
175 egress: fraiseql_kafka::KafkaEgress,
176}
177
178#[cfg(feature = "kafka")]
179impl std::fmt::Debug for KafkaAdapter {
180 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181 f.debug_struct("KafkaAdapter")
182 .field("endpoint", &self.config.endpoint)
183 .field("default_topic", &self.config.default_topic)
184 .field("client_id", &self.config.client_id)
185 .finish_non_exhaustive()
186 }
187}
188
189#[cfg(feature = "kafka")]
190impl KafkaAdapter {
191 /// Connect this adapter's shared Kafka egress.
192 ///
193 /// The endpoint is screened and `security.protocol` set from its scheme before any
194 /// client exists — by [`fraiseql_kafka::KafkaEgress`], the one place in this
195 /// workspace that builds a producer, so this transport and the CDC outbox sink
196 /// cannot disagree about what is safe (#1102).
197 ///
198 /// # Errors
199 ///
200 /// [`SubscriptionError::Internal`] if the endpoint is refused (no scheme,
201 /// unsupported scheme, unopted-in plaintext, blocked broker host, unresolvable SASL
202 /// credentials) or if the producer cannot be created.
203 pub fn new(config: KafkaConfig) -> Result<Self, SubscriptionError> {
204 use std::time::Duration;
205
206 // At-most-once, so both bounds are short: there is no outbox behind this and
207 // nothing will retry, which makes a long timeout a parked task rather than a
208 // second chance.
209 let egress_config = fraiseql_kafka::KafkaEgressConfig::new(config.client_id.clone())
210 .with_timeouts(
211 Duration::from_millis(config.timeout_ms),
212 Duration::from_millis(config.timeout_ms.min(1_000)),
213 )
214 .with_compression(config.compression.clone());
215
216 let egress = fraiseql_kafka::KafkaEgress::connect(&config.endpoint, &egress_config)
217 .map_err(|error| {
218 SubscriptionError::Internal(format!("Failed to create Kafka producer: {error}"))
219 })?;
220
221 tracing::info!(
222 endpoint = %config.endpoint,
223 topic = %config.default_topic,
224 client_id = %config.client_id,
225 "KafkaAdapter connected through the shared Kafka egress"
226 );
227
228 Ok(Self { config, egress })
229 }
230
231 /// Get the topic for a subscription (uses default if not specified).
232 fn get_topic(&self, _subscription_name: &str) -> &str {
233 // Could be extended to support per-subscription topic mapping
234 &self.config.default_topic
235 }
236}
237
238#[cfg(feature = "kafka")]
239// Reason: TransportAdapter is defined with #[async_trait]; all implementations must match
240// its transformed method signatures to satisfy the trait contract
241// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
242#[async_trait]
243impl TransportAdapter for KafkaAdapter {
244 async fn deliver(
245 &self,
246 event: &SubscriptionEvent,
247 subscription_name: &str,
248 ) -> Result<(), SubscriptionError> {
249 use fraiseql_kafka::{EgressOutcome, EgressRecord};
250
251 let message = KafkaMessage::from_event(event, subscription_name);
252 let topic = self.get_topic(subscription_name);
253
254 let payload = serde_json::to_string(&message).map_err(|e| {
255 SubscriptionError::Internal(format!("Failed to serialize message: {e}"))
256 })?;
257
258 let outcome = self
259 .egress
260 .send(EgressRecord {
261 topic,
262 key: message.key(),
263 payload: payload.as_bytes(),
264 headers: &[("fraiseql-event-id", &message.event_id)],
265 })
266 .await;
267
268 // At-most-once. Both failures are reported, neither is retried here: there is no
269 // outbox behind this transport, so a retry loop would be a second durability
270 // story to keep in step with the CDC sink's. The distinction survives in the
271 // message because it is what an operator needs — "the broker was down" and "this
272 // message can never be accepted" call for different responses.
273 match outcome {
274 EgressOutcome::Delivered => {
275 tracing::debug!(
276 topic = topic,
277 key = message.key(),
278 event_id = %event.event_id,
279 "Kafka message delivered successfully"
280 );
281 Ok(())
282 },
283 EgressOutcome::Transient(reason) => {
284 tracing::error!(
285 topic = topic,
286 key = message.key(),
287 event_id = %event.event_id,
288 %reason,
289 "Kafka delivery failed; at-most-once, so this event is not retried"
290 );
291 Err(SubscriptionError::DeliveryFailed {
292 transport: "kafka".to_string(),
293 reason,
294 })
295 },
296 EgressOutcome::Permanent(reason) => {
297 tracing::error!(
298 topic = topic,
299 key = message.key(),
300 event_id = %event.event_id,
301 %reason,
302 "Kafka refused this message permanently; redelivery could not succeed"
303 );
304 Err(SubscriptionError::DeliveryFailed {
305 transport: "kafka".to_string(),
306 reason,
307 })
308 },
309 }
310 }
311
312 fn name(&self) -> &'static str {
313 "kafka"
314 }
315
316 async fn health_check(&self) -> bool {
317 // The egress owns the producer, so it owns the metadata call too — a transport
318 // reaching for the raw client is the first step back towards a second producer.
319 self.egress.is_reachable(std::time::Duration::from_secs(5))
320 }
321}
322
323// =============================================================================
324// Kafka Adapter - Stub Implementation (without `kafka` feature)
325// =============================================================================
326
327/// Kafka transport adapter stub (without `kafka` feature).
328///
329/// The stub fails loud: every `deliver` returns an error and `health_check`
330/// reports unhealthy, so a deployment configured for Kafka on a binary built
331/// without it cannot silently drop events. Enable the `kafka` feature for
332/// actual Kafka delivery.
333#[cfg(not(feature = "kafka"))]
334#[derive(Debug)]
335pub struct KafkaAdapter {
336 config: KafkaConfig,
337}
338
339#[cfg(not(feature = "kafka"))]
340impl KafkaAdapter {
341 /// Create a new Kafka adapter stub.
342 ///
343 /// # Note
344 ///
345 /// This is a stub implementation: it can be constructed (so configuration
346 /// plumbing keeps working), but every `deliver` fails and `health_check`
347 /// reports unhealthy. Enable the `kafka` feature for actual delivery.
348 ///
349 /// # Errors
350 ///
351 /// Construction never fails; the `Result` mirrors the real adapter's API.
352 pub fn new(config: KafkaConfig) -> Result<Self, SubscriptionError> {
353 tracing::warn!(
354 endpoint = %config.endpoint,
355 topic = %config.default_topic,
356 "KafkaAdapter created (STUB - enable 'kafka' feature for real Kafka support)"
357 );
358 Ok(Self { config })
359 }
360
361 /// Get the topic for a subscription (uses default if not specified).
362 fn get_topic(&self, _subscription_name: &str) -> &str {
363 &self.config.default_topic
364 }
365}
366
367#[cfg(not(feature = "kafka"))]
368// Reason: TransportAdapter is defined with #[async_trait]; all implementations must match
369// its transformed method signatures to satisfy the trait contract
370// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
371#[async_trait]
372impl TransportAdapter for KafkaAdapter {
373 async fn deliver(
374 &self,
375 event: &SubscriptionEvent,
376 subscription_name: &str,
377 ) -> Result<(), SubscriptionError> {
378 // Fail loud (#784): reporting Ok here would drop the event while
379 // signalling successful delivery — the fail-open shape the other
380 // compiled-out runtime stubs (functions WASM/Deno, observers cache,
381 // NATS) deliberately avoid.
382 let topic = self.get_topic(subscription_name);
383 Err(SubscriptionError::Internal(format!(
384 "Kafka transport is not compiled into this binary (event {event_id} for topic \
385 '{topic}' not delivered). Rebuild with `--features kafka`.",
386 event_id = event.event_id,
387 )))
388 }
389
390 fn name(&self) -> &'static str {
391 "kafka"
392 }
393
394 async fn health_check(&self) -> bool {
395 // The stub can deliver nothing, so it is never healthy.
396 tracing::debug!("Kafka health check (STUB): kafka feature not compiled in — unhealthy");
397 false
398 }
399}