Skip to main content

photon_backend_fluvio/
port.rs

1//! Fluvio [`StoragePort`] implementation.
2
3use std::pin::Pin;
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use chrono::Utc;
8use futures::stream::Stream;
9use photon_backend::models::Event;
10use photon_backend::{
11    seal_event_for_storage, PhotonError, Result, StorageCapabilities, StoragePort,
12};
13use serde_json::Value;
14use uuid::Uuid;
15
16use crate::checkpoint::CheckpointStore;
17use crate::config::{FluvioConfig, FluvioStoragePortBuilder, ReplayCursor, ENDPOINT_ENV};
18use crate::connect::{connect_fluvio, SharedClient};
19use crate::consumer::subscribe_stream;
20use crate::publish::PublishPipeline;
21use crate::stream_shard::{composite_seq, fluvio_topic_for, pick_shard, publish_routing_key};
22use crate::topic::{ensure_checkpoint_topic, ensure_data_topic, warn_replication_settings};
23
24/// Read Fluvio endpoint from the environment.
25///
26/// # Errors
27///
28/// Returns an error when `PHOTON_FLUVIO_ENDPOINT` is unset.
29pub fn fluvio_endpoint_from_env() -> Result<String> {
30    std::env::var(ENDPOINT_ENV).map_err(|_| {
31        PhotonError::Internal(format!("{ENDPOINT_ENV} not set for fluvio storage adapter"))
32    })
33}
34
35/// Fluvio-backed storage port.
36pub struct FluvioStoragePort {
37    client: SharedClient,
38    config: FluvioConfig,
39    pipeline: PublishPipeline,
40    checkpoint_store: CheckpointStore,
41    ensured_topics: Arc<dashmap::DashSet<String>>,
42}
43
44impl FluvioStoragePort {
45    /// Start a builder for explicit host wiring.
46    #[must_use]
47    pub fn builder() -> FluvioStoragePortBuilder {
48        FluvioStoragePortBuilder::new()
49    }
50
51    /// Connect using env (`PHOTON_FLUVIO_*` defaults via builder).
52    ///
53    /// # Errors
54    ///
55    /// Returns an error when env is missing or connection fails.
56    pub async fn from_env() -> Result<Self> {
57        Self::builder().from_env_defaults().build().await
58    }
59
60    /// Resolved adapter configuration.
61    #[must_use]
62    pub const fn config(&self) -> &FluvioConfig {
63        &self.config
64    }
65
66    async fn connect_with_config(config: FluvioConfig) -> Result<Self> {
67        warn_replication_settings(&config);
68        let client = connect_fluvio(&config).await?;
69        ensure_checkpoint_topic(&client, &config).await?;
70        let checkpoint_store = CheckpointStore::connect(Arc::clone(&client), &config).await?;
71        let pipeline = PublishPipeline::new(Arc::clone(&client), &config);
72        Ok(Self {
73            client,
74            config,
75            pipeline,
76            checkpoint_store,
77            ensured_topics: Arc::new(dashmap::DashSet::new()),
78        })
79    }
80
81    async fn ensure_topic_once(&self, topic: &str) -> Result<()> {
82        if self.ensured_topics.contains(topic) {
83            return Ok(());
84        }
85        ensure_data_topic(&self.client, &self.config, topic).await?;
86        self.ensured_topics.insert(topic.to_string());
87        Ok(())
88    }
89}
90
91impl FluvioStoragePortBuilder {
92    /// Connect and return a configured [`FluvioStoragePort`].
93    ///
94    /// # Errors
95    ///
96    /// Returns an error when configuration or connection fails.
97    pub async fn build(self) -> Result<FluvioStoragePort> {
98        let config = self.resolve()?;
99        FluvioStoragePort::connect_with_config(config).await
100    }
101}
102
103#[async_trait]
104impl StoragePort for FluvioStoragePort {
105    fn capabilities(&self) -> StorageCapabilities {
106        StorageCapabilities::broker("fluvio")
107    }
108
109    async fn append(
110        &self,
111        topic_name: &str,
112        topic_key: Option<&str>,
113        actor_json: Value,
114        payload_json: Value,
115    ) -> Result<Event> {
116        let event = Event {
117            event_id: Uuid::new_v4().to_string(),
118            topic_name: topic_name.to_string(),
119            topic_key: topic_key.map(String::from),
120            seq: 0,
121            actor_json,
122            payload_json,
123            created_at: Utc::now(),
124        };
125        let (mut plain, sealed) = seal_event_for_storage(&self.config.crypto, event)?;
126
127        let routing = publish_routing_key(topic_key, &plain.event_id);
128        let shard = pick_shard(&routing, self.config.topic_shards);
129        let fluvio_topic = fluvio_topic_for(&self.config, shard, topic_name);
130        self.ensure_topic_once(&fluvio_topic).await?;
131
132        let offset_seq = self.pipeline.publish(&fluvio_topic, &sealed).await?;
133
134        if self.config.replay_cursor == ReplayCursor::StreamSeq {
135            if let Some(seq) = offset_seq {
136                plain.seq = if self.config.is_sharded() {
137                    composite_seq(shard, u64::try_from(seq.max(0)).unwrap_or(0))
138                } else {
139                    seq
140                };
141            }
142        }
143
144        Ok(plain)
145    }
146
147    fn subscribe(
148        &self,
149        topic_name: String,
150        topic_key_filter: Option<String>,
151        after_seq: Option<i64>,
152    ) -> Pin<Box<dyn Stream<Item = Result<Event>> + Send>> {
153        let effective_after = if self.config.replay_cursor == ReplayCursor::TailOnly {
154            None
155        } else {
156            after_seq
157        };
158        subscribe_stream(
159            Arc::clone(&self.client),
160            self.config.clone(),
161            self.checkpoint_store.clone(),
162            Arc::clone(&self.ensured_topics),
163            topic_name,
164            topic_key_filter,
165            effective_after,
166        )
167    }
168
169    async fn get_event(&self, _event_id: &str) -> Result<Option<Event>> {
170        Ok(None)
171    }
172
173    async fn list_by_topic(
174        &self,
175        _topic_name: &str,
176        _topic_key: Option<&str>,
177        _after_seq: Option<i64>,
178        _limit: usize,
179    ) -> Result<Vec<Event>> {
180        Ok(Vec::new())
181    }
182
183    async fn list_recent(&self, _limit: usize) -> Result<Vec<Event>> {
184        Ok(Vec::new())
185    }
186
187    #[allow(clippy::unused_async)] // `StoragePort` async trait; load is an in-memory cache read
188    async fn load_checkpoint(
189        &self,
190        subscription_name: &str,
191        topic_name: &str,
192        topic_key: Option<&str>,
193    ) -> Result<Option<i64>> {
194        if self.config.replay_cursor == ReplayCursor::TailOnly {
195            return Ok(None);
196        }
197        self.checkpoint_store
198            .load(subscription_name, topic_name, topic_key)
199    }
200
201    async fn commit_checkpoint(
202        &self,
203        subscription_name: &str,
204        topic_name: &str,
205        topic_key: Option<&str>,
206        last_seq: i64,
207    ) -> Result<()> {
208        if self.config.replay_cursor == ReplayCursor::TailOnly {
209            return Ok(());
210        }
211        self.checkpoint_store
212            .commit(subscription_name, topic_name, topic_key, last_seq)
213            .await
214    }
215}