Skip to main content

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}