Skip to main content

ruststream_sea_file/
file.rs

1//! The file transport: [`FileBroker`] -> [`ConnectedFileBroker`], a persistent, replayable
2//! stream on disk.
3
4use std::fs;
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, Ordering};
7
8use ruststream::{
9    Broker, ConnectedBroker, DefaultPublish, DescribeServer, OutgoingMessage, PairError,
10    PublishPolicy, Publisher, ServerSpec, Subscribe,
11};
12use sea_streamer_file::{
13    AutoStreamReset, FileConnectOptions, FileConsumerOptions, FileErr, FileId, FileProducer,
14    FileProducerOptions, FileStreamer,
15};
16use sea_streamer_types::{
17    ConsumerMode, ConsumerOptions as _, Producer as _, StreamErr, StreamKey, Streamer as _,
18};
19use tokio::sync::OnceCell;
20
21use crate::error::{SeaFileError, box_err};
22use crate::stream::FileStream;
23use crate::subscriber::FileSubscriber;
24use crate::wire;
25
26pub(crate) struct Core {
27    pub(crate) streamer: FileStreamer,
28    pub(crate) producer: FileProducer,
29    pub(crate) path: String,
30    pub(crate) closed: AtomicBool,
31}
32
33impl Core {
34    pub(crate) fn ensure_open(&self) -> Result<(), SeaFileError> {
35        if self.closed.load(Ordering::Acquire) {
36            return Err(SeaFileError::NotConnected);
37        }
38        Ok(())
39    }
40}
41
42impl std::fmt::Debug for Core {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_struct("Core")
45            .field("path", &self.path)
46            .field("closed", &self.closed.load(Ordering::Relaxed))
47            .finish_non_exhaustive()
48    }
49}
50
51pub(crate) type CoreCell = Arc<OnceCell<Arc<Core>>>;
52
53/// The internal stream key the connect-time priming marker rides; no user subscription
54/// matches it.
55const PRIME_STREAM: &str = "ruststream-internal";
56
57/// A persistent, replayable stream on disk for the `RustStream` messaging framework: what
58/// survives restarts, records for replay, and needs no external broker.
59///
60/// `new` is synchronous and records only the path; the file opens in the consuming
61/// [`Broker::connect`]. Not supported on Windows (an upstream constraint of the file client).
62///
63/// # Examples
64///
65/// ```
66/// use ruststream_sea_file::FileBroker;
67///
68/// let broker = FileBroker::new("/var/lib/orders.ss");
69/// # let _ = broker;
70/// ```
71#[derive(Debug, Clone)]
72#[must_use]
73pub struct FileBroker {
74    path: String,
75    create: bool,
76    end_with_eos: bool,
77    beacon_interval: Option<u32>,
78    cell: CoreCell,
79}
80
81impl FileBroker {
82    /// Records the path of the stream file (created on connect when missing). No I/O.
83    pub fn new(path: impl Into<String>) -> Self {
84        Self {
85            path: path.into(),
86            create: true,
87            end_with_eos: false,
88            beacon_interval: None,
89            cell: Arc::new(OnceCell::new()),
90        }
91    }
92
93    /// Requires the file to exist instead of creating it on connect.
94    pub fn existing_only(mut self) -> Self {
95        self.create = false;
96        self
97    }
98
99    /// Writes an end-of-stream mark when the broker shuts down, so replay consumers of the
100    /// finished file complete instead of waiting for more data.
101    pub fn end_with_eos(mut self) -> Self {
102        self.end_with_eos = true;
103        self
104    }
105
106    /// The interval of the file's in-place index (must be a positive multiple of 1024);
107    /// denser beacons make seeking finer-grained at the cost of file size.
108    pub fn beacon_interval(mut self, bytes: u32) -> Self {
109        self.beacon_interval = Some(bytes);
110        self
111    }
112
113    /// A publisher sharing this broker's connection cell; buildable before `connect`.
114    #[must_use]
115    pub fn publisher(&self) -> FilePublisher {
116        FilePublisher {
117            cell: Arc::clone(&self.cell),
118        }
119    }
120}
121
122impl Broker for FileBroker {
123    type Error = SeaFileError;
124    type Connected = ConnectedFileBroker;
125
126    async fn connect(self) -> Result<Self::Connected, Self::Error> {
127        let core = self
128            .cell
129            .get_or_try_init(async || {
130                let mut options = FileConnectOptions::default();
131                if self.create {
132                    options.set_create_if_not_exists(true);
133                }
134                if self.end_with_eos {
135                    options.set_end_with_eos(true);
136                }
137                if let Some(interval) = self.beacon_interval {
138                    options.set_beacon_interval(interval).map_err(|e| {
139                        SeaFileError::Invalid(format!("invalid beacon interval: {e}"))
140                    })?;
141                }
142                let file_id = FileId::new(self.path.clone());
143                let uri = file_id
144                    .to_streamer_uri()
145                    .map_err(|e| SeaFileError::Connect {
146                        target: self.path.clone(),
147                        source: box_err(e),
148                    })?;
149                let streamer = FileStreamer::connect(uri, options).await.map_err(|e| {
150                    SeaFileError::Connect {
151                        target: self.path.clone(),
152                        source: box_err(e),
153                    }
154                })?;
155                let connect_err = |e: StreamErr<FileErr>| SeaFileError::Connect {
156                    target: self.path.clone(),
157                    source: box_err(e),
158                };
159                let producer = streamer
160                    .create_generic_producer(FileProducerOptions::default())
161                    .await
162                    .map_err(connect_err)?;
163                // Prime a fresh file with one marker on an internal stream key: the client
164                // cannot finish creating a live consumer on a file with no content, and the
165                // marker rides a key no user subscription matches.
166                let fresh = fs::metadata(&self.path).map_or(true, |meta| meta.len() <= 128);
167                if fresh {
168                    let prime_key = StreamKey::new(PRIME_STREAM)
169                        .map_err(|e| SeaFileError::Invalid(e.to_string()))?;
170                    producer
171                        .send_to(&prime_key, b"1".as_slice())
172                        .map_err(connect_err)?
173                        .await
174                        .map_err(connect_err)?;
175                    let mut flusher = producer.clone();
176                    flusher.flush().await.map_err(connect_err)?;
177                }
178                Ok::<_, SeaFileError>(Arc::new(Core {
179                    streamer,
180                    producer,
181                    path: self.path.clone(),
182                    closed: AtomicBool::new(false),
183                }))
184            })
185            .await?
186            .clone();
187        Ok(ConnectedFileBroker {
188            core,
189            cell: self.cell,
190        })
191    }
192}
193
194impl DescribeServer for FileBroker {
195    fn describe_server(&self) -> ServerSpec {
196        ServerSpec::in_process("file").with_description(self.path.clone())
197    }
198}
199
200/// The typed witness that `connect` succeeded: the file is open.
201#[derive(Debug)]
202pub struct ConnectedFileBroker {
203    pub(crate) core: Arc<Core>,
204    // Keeps the cell of publishers handed out before connect alive and filled.
205    cell: CoreCell,
206}
207
208impl ConnectedFileBroker {
209    /// A publisher from the connected form.
210    #[must_use]
211    pub fn publisher(&self) -> FilePublisher {
212        FilePublisher {
213            cell: Arc::clone(&self.cell),
214        }
215    }
216
217    /// Opens the subscription described by `descriptor`.
218    ///
219    /// # Errors
220    ///
221    /// Returns [`SeaFileError`] when the descriptor is invalid, the consumer cannot be
222    /// created, or the broker is shut down.
223    pub async fn subscribe_stream(
224        &self,
225        descriptor: FileStream,
226    ) -> Result<FileSubscriber, SeaFileError> {
227        descriptor.validate()?;
228        self.core.ensure_open()?;
229
230        let key = StreamKey::new(descriptor.stream())
231            .map_err(|e| SeaFileError::Invalid(format!("'{}': {e}", descriptor.stream())))?;
232        let mut options = FileConsumerOptions::new(ConsumerMode::RealTime);
233        // A live subscription tails the file; where reading begins is the framework's
234        // start_at / Seek surface. Replay is the one mode a seek cannot express: it reads
235        // the retained file from the start and completes the stream at its end.
236        options.set_auto_stream_reset(if descriptor.replay_value() {
237            AutoStreamReset::Earliest
238        } else {
239            AutoStreamReset::Latest
240        });
241        options.set_live_streaming(!descriptor.replay_value());
242        let consumer = self
243            .core
244            .streamer
245            .create_consumer(&[key], options)
246            .await
247            .map_err(|e| SeaFileError::Subscribe {
248                stream: descriptor.stream().to_owned(),
249                source: box_err(e),
250            })?;
251        Ok(FileSubscriber::spawn(
252            descriptor.stream().to_owned(),
253            consumer,
254            descriptor.replay_value(),
255        ))
256    }
257}
258
259impl ConnectedBroker for ConnectedFileBroker {
260    type Error = SeaFileError;
261    type Closed = ();
262
263    async fn shutdown(self) -> Result<(), Self::Error> {
264        self.core.closed.store(true, Ordering::Release);
265        // Ends the file's shared producers (writing the end-of-stream mark when configured)
266        // and flushes to disk. An already-ended producer is benign teardown noise: another
267        // broker over the same file finished first.
268        match self.core.streamer.clone().disconnect().await {
269            Ok(()) | Err(StreamErr::Backend(FileErr::ProducerEnded)) => Ok(()),
270            Err(e) => Err(SeaFileError::Connect {
271                target: self.core.path.clone(),
272                source: box_err(e),
273            }),
274        }
275    }
276}
277
278impl Subscribe for ConnectedFileBroker {
279    type Subscriber = FileSubscriber;
280
281    async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
282        self.subscribe_stream(FileStream::new(name)).await
283    }
284}
285
286/// Publishes messages into the stream file.
287///
288/// User headers travel in a text-safe envelope applied only when headers are present, so a
289/// file written without headers stays readable as a plain payload stream by other tools.
290#[derive(Clone)]
291pub struct FilePublisher {
292    cell: CoreCell,
293}
294
295impl std::fmt::Debug for FilePublisher {
296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297        f.debug_struct("FilePublisher").finish_non_exhaustive()
298    }
299}
300
301impl Publisher for FilePublisher {
302    type Error = SeaFileError;
303
304    async fn publish(&self, msg: OutgoingMessage<'_>) -> Result<(), Self::Error> {
305        let core = self.cell.get().ok_or(SeaFileError::NotConnected)?;
306        core.ensure_open()?;
307        let producer = &core.producer;
308        let key = StreamKey::new(msg.name())
309            .map_err(|e| SeaFileError::Invalid(format!("'{}': {e}", msg.name())))?;
310        let payload = wire::encode(msg.headers(), msg.payload(), false);
311        producer
312            .send_to(&key, payload.as_slice())
313            .map_err(|e| SeaFileError::Publish {
314                stream: msg.name().to_owned(),
315                source: box_err(e),
316            })?
317            .await
318            .map_err(|e| SeaFileError::Publish {
319                stream: msg.name().to_owned(),
320                source: box_err(e),
321            })?;
322        // Flush per publish: the sink buffers, and live subscribers (and external tails)
323        // observe the file, not the buffer. A clone shares the same sink.
324        let mut flusher = producer.clone();
325        flusher.flush().await.map_err(|e| SeaFileError::Publish {
326            stream: msg.name().to_owned(),
327            source: box_err(e),
328        })
329    }
330}
331
332/// The publish policy for [`FilePublisher`].
333///
334/// # Examples
335///
336/// ```
337/// use ruststream_sea_file::FilePublish;
338///
339/// let policy = FilePublish::default();
340/// # let _ = policy;
341/// ```
342#[derive(Debug, Clone, Copy, Default)]
343#[must_use]
344pub struct FilePublish;
345
346impl PublishPolicy<ConnectedFileBroker> for FilePublish {
347    type Live = FilePublisher;
348
349    async fn pair(self, connected: &ConnectedFileBroker) -> Result<Self::Live, PairError> {
350        Ok(connected.publisher())
351    }
352}
353
354impl DefaultPublish for ConnectedFileBroker {
355    type Policy = FilePublish;
356}