Skip to main content

ruststream_sea_file/
file.rs

1//! The stream file: a log on disk that survives restarts, replays and repositions, with no server.
2//!
3//! [`FileBroker`] records the path of a `.ss` file and opens it on [`Broker::connect`], which
4//! yields [`ConnectedFileBroker`]; [`ConnectedBroker::shutdown`] syncs and closes it. A service on
5//! stream files globs this form's [`prelude`] and names its policy [`Publish`].
6//!
7//! - [FileBroker][file-broker]: [`FileBroker`] and its settings
8//!   [`existing_only`](FileBroker::existing_only), [`end_with_eos`](FileBroker::end_with_eos)
9//!   and [`beacon_interval`](FileBroker::beacon_interval).
10//! - [FileStream][file-stream]: [`FileStream`], one stream key of the file,
11//!   and its [`replay`](FileStream::replay) mode.
12//! - [FilePosition][positions]: [`FilePosition`](crate::FilePosition), opened with
13//!   [`start_at`][start-at] and moved with [`SeekHandle`](crate::SeekHandle) ([Seek][seek]).
14//! - [Batches][batches]: a slice handler over [`FileSubscriber`].
15//! - [FilePublish][file-publish]: [`Publish`] and its live [`FilePublisher`].
16//! - [retry_after][retry-after]: a deferred copy appended under the key the subscription reads.
17//! - [Shutdown][shutdown]: the file closed, and the publishers handed out before it.
18//!
19//! [file-broker]: https://powersemmi.github.io/ruststream-sea-file/latest/components/broker/file-broker/
20//! [file-stream]: https://powersemmi.github.io/ruststream-sea-file/latest/components/subscriptions/file-stream/
21//! [positions]: https://powersemmi.github.io/ruststream-sea-file/latest/components/positions/
22//! [start-at]: https://powersemmi.github.io/ruststream-sea-file/latest/components/positions/start-at/
23//! [seek]: https://powersemmi.github.io/ruststream-sea-file/latest/components/positions/seek/
24//! [batches]: https://powersemmi.github.io/ruststream-sea-file/latest/components/subscriptions/batches/
25//! [file-publish]: https://powersemmi.github.io/ruststream-sea-file/latest/components/publishing/file-publish/
26//! [retry-after]: https://powersemmi.github.io/ruststream-sea-file/latest/components/settlement/retry-after/
27//! [shutdown]: https://powersemmi.github.io/ruststream-sea-file/latest/components/broker/shutdown/
28
29// Without the `testing` feature a link has one variant, so a `match` on it has a single arm; the
30// matches stay so that the in-process arm has its place when the feature is on.
31#![cfg_attr(
32    not(feature = "testing"),
33    allow(clippy::infallible_destructuring_match)
34)]
35
36use std::collections::HashMap;
37use std::fs;
38use std::future::{Future, ready};
39use std::mem;
40use std::sync::atomic::{AtomicBool, Ordering};
41use std::sync::{Arc, LazyLock, Weak};
42
43#[cfg(feature = "testing")]
44use ruststream::RawMessage;
45#[cfg(feature = "asyncapi")]
46use ruststream::asyncapi::Bindings;
47#[cfg(feature = "testing")]
48use ruststream::testing::{Coordinator, InProcess, TestableBroker};
49use ruststream::{
50    AddressedCopies, Broker, ConnectedBroker, DefaultPublish, DescribeServer, Lend,
51    OutgoingMessage, PairError, PublishPolicy, Publisher, ServerSpec, Subscribe,
52};
53use sea_streamer_file::{
54    AutoStreamReset, FileConnectOptions, FileConsumerOptions, FileErr, FileId, FileProducer,
55    FileProducerOptions, FileStreamer, MessageSource, StreamMode,
56};
57use sea_streamer_types::{
58    ConsumerMode, ConsumerOptions as _, Producer as _, StreamErr, StreamKey, Streamer as _,
59};
60use tokio::runtime::Handle;
61use tokio::sync::{Mutex, OnceCell};
62
63use crate::error::{SeaFileError, box_err};
64#[cfg(feature = "testing")]
65use crate::in_process::MemoryFile;
66use crate::stream::FileStream;
67#[cfg(feature = "asyncapi")]
68use crate::stream::channel_extension;
69use crate::subscriber::{FileSubscriber, Reader};
70use crate::wire;
71
72pub(crate) struct Core {
73    pub(crate) link: Link,
74    pub(crate) path: String,
75    pub(crate) closed: AtomicBool,
76}
77
78/// The file's client handles: the streamer every consumer is created from and the producer every
79/// publish appends through.
80pub(crate) struct Disk {
81    pub(crate) streamer: FileStreamer,
82    /// Taken out and dropped by `shutdown`, never later.
83    ///
84    /// Why the lock is exclusive: once an append fails, the client's writer ends and leaves every
85    /// request queued behind the failing one unanswered, so a publish issued beside it would never
86    /// return. One append at a time keeps nothing queued behind a failure, and the failure is
87    /// recorded so no later publish reaches the ended writer. The client also counts producer
88    /// handles per path, on one process-wide task, and applies a dropped handle to whichever
89    /// writer holds the path when the drop is processed: a handle this connection dropped after
90    /// its writer ended would end the writer of the next connection over the same path, so
91    /// `shutdown` takes the handle out under the lock, once the publish in flight is done with it.
92    ///
93    /// Brokers opened on one path in one process share the client's writer, so they share this
94    /// appender too: one lock per file, not per broker.
95    pub(crate) producer: Arc<Mutex<Appender>>,
96    /// The runtime `connect` ran on. Every task of this connection runs there: the subscription
97    /// drivers this crate starts, and the readers the client starts when a consumer opens, so a
98    /// subscription opened from another runtime does not stop when that runtime does.
99    pub(crate) runtime: Handle,
100}
101
102/// The producer of a connection, from connect to shutdown.
103pub(crate) enum Appender {
104    /// Appends go through.
105    Open(FileProducer),
106    /// An append failed and the client's writer ended with it; the handle is kept for `shutdown`
107    /// to drop in order, and every later publish reports the failure.
108    Failed(FileProducer, FileErr),
109    /// `shutdown` took the handle out.
110    Closed,
111}
112
113/// The appender of every stream file a broker of this process has open, by path.
114static APPENDERS: LazyLock<Mutex<HashMap<String, Weak<Mutex<Appender>>>>> =
115    LazyLock::new(|| Mutex::new(HashMap::new()));
116
117/// The appender a broker opened earlier on `path` and has not shut down, which a broker opened
118/// on the same path appends through as well.
119async fn open_appender(
120    appenders: &HashMap<String, Weak<Mutex<Appender>>>,
121    path: &str,
122) -> Option<Arc<Mutex<Appender>>> {
123    let shared = appenders.get(path)?.upgrade()?;
124    let open = !matches!(*shared.lock().await, Appender::Closed);
125    open.then_some(shared)
126}
127
128/// What a connected broker and every handle paired off it speak over: the stream file, or, under
129/// the `testing` feature, the in-memory one the test harness connected instead.
130///
131/// Without the feature there is one variant, so the type is the client handles themselves and
132/// every `match` on it is irrefutable: a production build carries no second transport and no
133/// branch to it.
134pub(crate) enum Link {
135    File(Disk),
136    #[cfg(feature = "testing")]
137    InProcess(Arc<MemoryFile>),
138}
139
140// The zero-cost promise of the in-process mode, held by the compiler: a build without it gives the
141// link exactly the size of the client handles it wraps.
142#[cfg(not(feature = "testing"))]
143const _: () = assert!(size_of::<Link>() == size_of::<Disk>());
144
145impl Core {
146    pub(crate) fn ensure_open(&self) -> Result<(), SeaFileError> {
147        if self.closed.load(Ordering::Acquire) {
148            return Err(SeaFileError::NotConnected);
149        }
150        Ok(())
151    }
152}
153
154impl std::fmt::Debug for Core {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        f.debug_struct("Core")
157            .field("path", &self.path)
158            .field("closed", &self.closed.load(Ordering::Relaxed))
159            .finish_non_exhaustive()
160    }
161}
162
163pub(crate) type CoreCell = Arc<OnceCell<Arc<Core>>>;
164
165/// The internal stream key the connect-time priming marker rides; no user subscription
166/// matches it.
167const PRIME_STREAM: &str = "ruststream-internal";
168
169/// A log kept in one `.ss` stream file for the `RustStream` messaging framework: it survives
170/// restarts, replays what it recorded, and needs no server.
171///
172/// `new` is synchronous and records only the path; the file opens in the consuming
173/// [`Broker::connect`], which creates it when it is missing. The file transport builds on Unix
174/// systems only, an upstream constraint of the file client.
175///
176/// A publish appends the message under its stream key and flushes it, so the bytes reach the
177/// operating system before the call returns; the file is synced to the device at shutdown. The
178/// file client caps a stream file at 16 GiB: a publish that reaches the cap fails, and every
179/// later publish to that file fails too. Brokers opened on one path in one process share the
180/// file's writer: once one of them shuts down, a publish through another fails with
181/// [`SeaFileError::Publish`] carrying the client's `ProducerEnded`.
182///
183/// The [FileBroker](https://powersemmi.github.io/ruststream-sea-file/latest/components/broker/file-broker/)
184/// page shows it in a service.
185///
186/// # Examples
187///
188/// ```
189/// use ruststream_sea_file::file::prelude::*;
190/// use serde::Deserialize;
191///
192/// #[derive(Debug, Deserialize)]
193/// struct Order {
194///     id: u64,
195/// }
196///
197/// #[subscriber(FileStream::new("orders"), start_at(FilePosition::beginning()))]
198/// async fn handle(order: &Order) -> HandlerOutcome {
199///     tracing::info!(order.id, "accepted");
200///     HandlerOutcome::ack()
201/// }
202///
203/// #[ruststream::app]
204/// fn app() -> impl App {
205///     RustStream::new(AppInfo::new("orders", "0.1.0"))
206///         .with_broker(FileBroker::new("/var/lib/orders.ss").end_with_eos(), |b| {
207///             b.include(handle);
208///         })
209/// }
210/// ```
211///
212/// A test runs the app `main` runs. With the `testing` feature, `TestApp::start` connects this
213/// broker to a stream file held in memory, and the test addresses it by its production type:
214///
215/// ```
216/// # #[cfg(feature = "testing")]
217/// # mod demo {
218/// use std::error::Error;
219///
220/// use ruststream::testing::TestApp;
221/// use ruststream_sea_file::file::prelude::*;
222/// use serde::{Deserialize, Serialize};
223///
224/// #[derive(Debug, Deserialize, Serialize, Outgoing, PartialEq)]
225/// #[outgoing(name = "orders")]
226/// pub struct Order {
227///     pub id: u64,
228/// }
229///
230/// #[derive(Debug, Deserialize, Serialize, Outgoing, PartialEq)]
231/// #[outgoing(name = "receipts")]
232/// pub struct Receipt {
233///     pub order: u64,
234/// }
235///
236/// #[subscriber(FileStream::new("orders"), reply)]
237/// async fn confirm(order: &Order) -> Receipt {
238///     Receipt { order: order.id }
239/// }
240///
241/// /// The app `main` runs.
242/// pub fn app() -> impl App {
243///     RustStream::new(AppInfo::new("orders", "0.1.0"))
244///         .with_broker(FileBroker::new("/var/lib/orders.ss"), |b| {
245///             b.include(confirm);
246///         })
247/// }
248///
249/// pub async fn confirms_an_order() -> Result<(), Box<dyn Error>> {
250///     let tb = TestApp::start(app()).await?;
251///
252///     // The publish returns once the handler it woke has settled.
253///     tb.broker::<FileBroker>()
254///         .message(&Order { id: 1 })
255///         .publish()
256///         .await?;
257///
258///     tb.broker::<FileBroker>()
259///         .subscriber("orders")
260///         .assert_called(1)
261///         .with(&Order { id: 1 });
262///     tb.broker::<FileBroker>()
263///         .published::<Receipt>("receipts")
264///         .assert_called(1)
265///         .with(&Receipt { order: 1 });
266///
267///     tb.shutdown().await?;
268///     Ok(())
269/// }
270/// # }
271/// # #[cfg(feature = "testing")]
272/// # #[tokio::main(flavor = "multi_thread", worker_threads = 2)]
273/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
274/// #     demo::confirms_an_order().await
275/// # }
276/// # #[cfg(not(feature = "testing"))]
277/// # fn main() {}
278/// ```
279#[derive(Debug, Clone)]
280#[must_use]
281pub struct FileBroker {
282    path: String,
283    create: bool,
284    end_with_eos: bool,
285    beacon_interval: Option<u32>,
286    /// The size the client caps the file at, lowered only by the unit tests, which cannot write
287    /// the client's default of 16 GiB.
288    #[cfg(test)]
289    size_limit: Option<u64>,
290    cell: CoreCell,
291}
292
293impl FileBroker {
294    /// Records the path of the stream file (created on connect when missing). No I/O.
295    pub fn new(path: impl Into<String>) -> Self {
296        Self {
297            path: path.into(),
298            create: true,
299            end_with_eos: false,
300            beacon_interval: None,
301            #[cfg(test)]
302            size_limit: None,
303            cell: Arc::new(OnceCell::new()),
304        }
305    }
306
307    #[cfg(test)]
308    const fn size_limit(mut self, bytes: u64) -> Self {
309        self.size_limit = Some(bytes);
310        self
311    }
312
313    /// Requires the file to exist instead of creating it on connect.
314    pub fn existing_only(mut self) -> Self {
315        self.create = false;
316        self
317    }
318
319    /// Writes an end-of-stream mark when the broker shuts down, marking the file finished.
320    ///
321    /// A subscription that tails the file reads the mark and ends there, which is the only
322    /// thing that ends a live subscription. A replay completes at the mark, or at the end of the
323    /// file when there is none, after delivering every message before it.
324    pub fn end_with_eos(mut self) -> Self {
325        self.end_with_eos = true;
326        self
327    }
328
329    /// The interval of the file's in-place index (must be a positive multiple of 1024);
330    /// denser beacons make seeking finer-grained at the cost of file size.
331    pub fn beacon_interval(mut self, bytes: u32) -> Self {
332        self.beacon_interval = Some(bytes);
333        self
334    }
335
336    /// A publisher sharing this broker's connection cell; buildable before `connect`.
337    #[must_use]
338    pub fn publisher(&self) -> FilePublisher {
339        FilePublisher {
340            cell: Arc::clone(&self.cell),
341        }
342    }
343}
344
345impl Broker for FileBroker {
346    type Error = SeaFileError;
347    type Connected = ConnectedFileBroker;
348
349    async fn connect(self) -> Result<Self::Connected, Self::Error> {
350        let core = self
351            .cell
352            .get_or_try_init(async || {
353                let mut options = FileConnectOptions::default();
354                if self.create {
355                    options.set_create_if_not_exists(true);
356                }
357                if self.end_with_eos {
358                    options.set_end_with_eos(true);
359                }
360                #[cfg(test)]
361                if let Some(bytes) = self.size_limit {
362                    options.set_file_size_limit(bytes);
363                }
364                if let Some(interval) = self.beacon_interval {
365                    options.set_beacon_interval(interval).map_err(|e| {
366                        SeaFileError::Invalid(format!("invalid beacon interval: {e}"))
367                    })?;
368                }
369                let file_id = FileId::new(self.path.clone());
370                let uri = file_id
371                    .to_streamer_uri()
372                    .map_err(|e| SeaFileError::Connect {
373                        target: self.path.clone(),
374                        source: box_err(e),
375                    })?;
376                let streamer = FileStreamer::connect(uri, options).await.map_err(|e| {
377                    SeaFileError::Connect {
378                        target: self.path.clone(),
379                        source: box_err(e),
380                    }
381                })?;
382                let connect_err = |e: StreamErr<FileErr>| SeaFileError::Connect {
383                    target: self.path.clone(),
384                    source: box_err(e),
385                };
386                let mut appenders = APPENDERS.lock().await;
387                if let Some(shared) = open_appender(&appenders, &self.path).await {
388                    return Ok(Arc::new(Core {
389                        link: Link::File(Disk {
390                            streamer,
391                            producer: shared,
392                            runtime: Handle::current(),
393                        }),
394                        path: self.path.clone(),
395                        closed: AtomicBool::new(false),
396                    }));
397                }
398                let producer = streamer
399                    .create_generic_producer(FileProducerOptions::default())
400                    .await
401                    .map_err(connect_err)?;
402                // Prime a fresh file with one marker on an internal stream key: the client
403                // cannot finish creating a live consumer on a file with no content, and the
404                // marker rides a key no user subscription matches.
405                let fresh = fs::metadata(&self.path).map_or(true, |meta| meta.len() <= 128);
406                if fresh {
407                    let prime_key = StreamKey::new(PRIME_STREAM)
408                        .map_err(|e| SeaFileError::Invalid(e.to_string()))?;
409                    producer
410                        .send_to(&prime_key, b"1".as_slice())
411                        .map_err(connect_err)?
412                        .await
413                        .map_err(connect_err)?;
414                    let mut flusher = producer.clone();
415                    flusher.flush().await.map_err(connect_err)?;
416                }
417                let shared = Arc::new(Mutex::new(Appender::Open(producer)));
418                appenders.retain(|_, appender| appender.strong_count() > 0);
419                appenders.insert(self.path.clone(), Arc::downgrade(&shared));
420                drop(appenders);
421                Ok::<_, SeaFileError>(Arc::new(Core {
422                    link: Link::File(Disk {
423                        streamer,
424                        producer: shared,
425                        runtime: Handle::current(),
426                    }),
427                    path: self.path.clone(),
428                    closed: AtomicBool::new(false),
429                }))
430            })
431            .await?
432            .clone();
433        Ok(ConnectedFileBroker {
434            core,
435            cell: self.cell,
436        })
437    }
438}
439
440/// The in-process mode: the connected form a test runs the production app against, over a stream
441/// file held in memory in place of the one at the path.
442///
443/// The file is new and empty, and every clone of this broker shares it, as clones share the file
444/// they connect. The settings are checked the way `connect` checks them, so a broker a service
445/// could not connect is not one a test can connect either: the path must form a file address, and
446/// the beacon interval must be a positive multiple of 1024. [`existing_only`](Self::existing_only)
447/// reads the in-memory file as the one the operator provisioned.
448#[cfg(feature = "testing")]
449impl InProcess for FileBroker {
450    async fn connect_in_process(self) -> Result<Self::Connected, Self::Error> {
451        let core = self
452            .cell
453            .get_or_try_init(async || {
454                if let Some(interval) = self.beacon_interval {
455                    FileConnectOptions::default()
456                        .set_beacon_interval(interval)
457                        .map_err(|e| {
458                            SeaFileError::Invalid(format!("invalid beacon interval: {e}"))
459                        })?;
460                }
461                FileId::new(self.path.clone())
462                    .to_streamer_uri()
463                    .map_err(|e| SeaFileError::Connect {
464                        target: self.path.clone(),
465                        source: box_err(e),
466                    })?;
467                Ok::<_, SeaFileError>(Arc::new(Core {
468                    link: Link::InProcess(MemoryFile::new(self.end_with_eos)),
469                    path: self.path.clone(),
470                    closed: AtomicBool::new(false),
471                }))
472            })
473            .await?
474            .clone();
475        Ok(ConnectedFileBroker {
476            core,
477            cell: self.cell,
478        })
479    }
480}
481
482#[cfg(feature = "testing")]
483ruststream::register_testable_broker!(FileBroker);
484
485impl DescribeServer for FileBroker {
486    fn describe_server(&self) -> ServerSpec {
487        ServerSpec::in_process("file").description(self.path.clone())
488    }
489}
490
491/// The typed witness that `connect` succeeded: the file is open.
492#[derive(Debug)]
493pub struct ConnectedFileBroker {
494    pub(crate) core: Arc<Core>,
495    // Keeps the cell of publishers handed out before connect alive and filled.
496    cell: CoreCell,
497}
498
499impl ConnectedFileBroker {
500    /// A publisher from the connected form.
501    #[must_use]
502    pub fn publisher(&self) -> FilePublisher {
503        FilePublisher {
504            cell: Arc::clone(&self.cell),
505        }
506    }
507
508    /// Opens the subscription described by `descriptor`.
509    ///
510    /// # Errors
511    ///
512    /// Returns [`SeaFileError`] when the descriptor is invalid, the consumer cannot be
513    /// created, or the broker is shut down.
514    pub async fn subscribe_stream(
515        &self,
516        descriptor: FileStream,
517    ) -> Result<FileSubscriber, SeaFileError> {
518        descriptor.validate()?;
519        self.core.ensure_open()?;
520
521        let key = StreamKey::new(descriptor.stream())
522            .map_err(|e| SeaFileError::Invalid(format!("'{}': {e}", descriptor.stream())))?;
523        let disk = match &self.core.link {
524            Link::File(disk) => disk,
525            #[cfg(feature = "testing")]
526            Link::InProcess(file) => {
527                return Ok(FileSubscriber::in_process(
528                    file.subscribe(key.name(), descriptor.replay_value()),
529                ));
530            }
531        };
532        // A live subscription tails the file through the client's consumer; where reading begins
533        // is the framework's start_at / Seek surface. Replay is the one mode a seek cannot
534        // express: it reads the retained file from the start and completes the stream at its end,
535        // and it reads the file itself - see `Reader::Replay` for why.
536        let stream = descriptor.stream().to_owned();
537        let subscribe_err = |source| SeaFileError::Subscribe {
538            stream: stream.clone(),
539            source,
540        };
541        let reader = if descriptor.replay_value() {
542            let file_id = FileId::new(self.core.path.clone());
543            let source = MessageSource::new(file_id, StreamMode::Replay)
544                .await
545                .map_err(|e| subscribe_err(box_err(e)))?;
546            Reader::Replay {
547                source: Box::new(source),
548                key,
549            }
550        } else {
551            let mut options = FileConsumerOptions::new(ConsumerMode::RealTime);
552            options.set_auto_stream_reset(AutoStreamReset::Latest);
553            // Stated rather than left to the client's default: a live subscription waits for the
554            // writes that follow the end of the file instead of finishing there.
555            options.set_live_streaming(true);
556            // The client starts the consumer's reader tasks on the runtime that opens it, so the
557            // opening runs on the connection's runtime: once per subscription, off the delivery
558            // path.
559            let streamer = disk.streamer.clone();
560            let consumer = disk
561                .runtime
562                .spawn(async move { streamer.create_consumer(&[key], options).await })
563                .await
564                .map_err(|e| subscribe_err(box_err(e)))?
565                .map_err(|e| subscribe_err(box_err(e)))?;
566            Reader::Tail(consumer)
567        };
568        let core = Arc::clone(&self.core);
569        Ok(FileSubscriber::spawn(
570            &disk.runtime,
571            stream,
572            reader,
573            move || core.closed.load(Ordering::Acquire),
574        ))
575    }
576}
577
578impl ConnectedBroker for ConnectedFileBroker {
579    type Error = SeaFileError;
580    type Closed = ();
581
582    async fn shutdown(self) -> Result<(), Self::Error> {
583        self.core.closed.store(true, Ordering::Release);
584        let disk = match &self.core.link {
585            Link::File(disk) => disk,
586            #[cfg(feature = "testing")]
587            Link::InProcess(file) => {
588                file.finish();
589                return Ok(());
590            }
591        };
592        // The lock waits for the publish in flight. A connection that another clone of this
593        // broker, or another broker on the same path, already shut down holds no handle and has
594        // nothing left to end.
595        let taken = mem::replace(&mut *disk.producer.lock().await, Appender::Closed);
596        let producer = match taken {
597            Appender::Open(producer) | Appender::Failed(producer, _) => producer,
598            Appender::Closed => return Ok(()),
599        };
600        // Ends the file's shared producers (writing the end-of-stream mark when configured)
601        // and flushes to disk. An already-ended producer is benign teardown noise: another
602        // broker over the same file finished first.
603        let ended = disk.streamer.clone().disconnect().await;
604        // The handle is dropped after the writer ended, so the end-of-stream mark is written; its
605        // drop reaches the client's task as a request of its own. The second end is answered by
606        // that task only after it processed the drop, against a path no writer holds any more, so
607        // `shutdown` returns once nothing of this connection can reach a later one over the file.
608        drop(producer);
609        let _settled = disk.streamer.clone().disconnect().await;
610        match ended {
611            Ok(()) | Err(StreamErr::Backend(FileErr::ProducerEnded)) => Ok(()),
612            Err(e) => Err(SeaFileError::Connect {
613                target: self.core.path.clone(),
614                source: box_err(e),
615            }),
616        }
617    }
618}
619
620impl Subscribe for ConnectedFileBroker {
621    type Subscriber = FileSubscriber;
622    /// A stream key is both ends of the file: a publisher on this broker appends under the key
623    /// the subscription tails, so the runtime's deferred copies have an address and the mount
624    /// site has nothing to name.
625    ///
626    /// This is what makes `retry_after` work on a stream file, and nothing else does: the
627    /// transport keeps no consumer positions, so a `nack` cannot hand the message back.
628    type Copies = AddressedCopies;
629
630    async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
631        self.subscribe_stream(FileStream::new(name)).await
632    }
633}
634
635/// Publishes messages into the stream file.
636///
637/// User headers travel in a text-safe envelope applied only when headers are present, so a
638/// file written without headers stays readable as a plain payload stream by other tools.
639///
640/// A publish carries no per-message settings: an append to a stream file takes a key and a
641/// payload and nothing else, so [`Publisher::Options`] is the unit type and the publish builder
642/// gains no step from this crate.
643///
644/// A destination is a stream key inside the broker, and the file itself is named once, in
645/// [`FileBroker::new`]. The
646/// [FilePublish](https://powersemmi.github.io/ruststream-sea-file/latest/components/publishing/file-publish/)
647/// page shows it in a service.
648///
649/// # Examples
650///
651/// A reply type declares its stream key, and the subscriber carries the bare `reply` clause:
652///
653/// ```
654/// use ruststream_sea_file::file::prelude::*;
655/// use serde::{Deserialize, Serialize};
656///
657/// #[derive(Debug, Deserialize)]
658/// struct Order {
659///     id: u64,
660/// }
661///
662/// #[derive(Debug, Outgoing, Serialize)]
663/// #[outgoing(name = "receipts")]
664/// struct Receipt {
665///     order: u64,
666/// }
667///
668/// #[subscriber(FileStream::new("orders"), reply)]
669/// async fn confirm(order: &Order) -> Receipt {
670///     tracing::info!(order.id, "confirmed");
671///     Receipt { order: order.id }
672/// }
673///
674/// #[ruststream::app]
675/// fn app() -> impl App {
676///     RustStream::new(AppInfo::new("orders", "0.1.0"))
677///         .with_broker(FileBroker::new("/tmp/orders.ss"), |b| {
678///             b.include(confirm).out_reply(Publish);
679///         })
680/// }
681/// ```
682#[derive(Clone)]
683pub struct FilePublisher {
684    cell: CoreCell,
685}
686
687impl std::fmt::Debug for FilePublisher {
688    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
689        f.debug_struct("FilePublisher").finish_non_exhaustive()
690    }
691}
692
693impl Publisher for FilePublisher {
694    /// The append reads the bytes and keeps nothing: the client's `send_to` takes a slice, and
695    /// the envelope this crate writes is a buffer of its own anyway.
696    type Payload = Lend;
697
698    type Error = SeaFileError;
699    type Options = ();
700
701    async fn publish(
702        &self,
703        msg: OutgoingMessage<'_, &[u8]>,
704        _options: Option<&()>,
705    ) -> Result<(), Self::Error> {
706        let core = self.cell.get().ok_or(SeaFileError::NotConnected)?;
707        core.ensure_open()?;
708        let key = StreamKey::new(msg.name())
709            .map_err(|e| SeaFileError::Invalid(format!("'{}': {e}", msg.name())))?;
710        let payload = wire::encode(msg.headers(), msg.payload(), false);
711        match &core.link {
712            Link::File(disk) => {
713                let mut appender = disk.producer.lock().await;
714                appender.append(msg.name(), &key, &payload).await
715            }
716            .map_err(|err| match err {
717                // The appender closed while this connection is open: another broker on the
718                // same path shut down and ended the file's writer.
719                SeaFileError::NotConnected if !core.closed.load(Ordering::Acquire) => {
720                    SeaFileError::Publish {
721                        stream: msg.name().to_owned(),
722                        source: box_err(FileErr::ProducerEnded),
723                    }
724                }
725                other => other,
726            }),
727            #[cfg(feature = "testing")]
728            Link::InProcess(file) => {
729                file.append(key, payload);
730                Ok(())
731            }
732        }
733    }
734}
735
736impl Appender {
737    /// Appends one message through an open producer, and records a failure that ended the
738    /// client's writer, so a later publish reports it instead of reaching that writer.
739    async fn append(
740        &mut self,
741        stream: &str,
742        key: &StreamKey,
743        payload: &[u8],
744    ) -> Result<(), SeaFileError> {
745        let failed = |source| SeaFileError::Publish {
746            stream: stream.to_owned(),
747            source,
748        };
749        let producer = match self {
750            Self::Open(producer) => producer,
751            Self::Failed(_, err) => return Err(failed(box_err(err.take()))),
752            // A `shutdown` ran between the open check and the lock.
753            Self::Closed => return Err(SeaFileError::NotConnected),
754        };
755        let mut cause = match append(producer, key, payload).await {
756            Ok(()) => return Ok(()),
757            Err(StreamErr::Backend(cause)) => cause,
758            Err(other) => return Err(failed(box_err(other))),
759        };
760        let reported = cause.take();
761        if let Self::Open(producer) = mem::replace(self, Self::Closed) {
762            *self = Self::Failed(producer, cause);
763        }
764        Err(failed(box_err(reported)))
765    }
766}
767
768/// Appends one message and flushes it to the file.
769///
770/// The flush is per publish: the sink buffers, and live subscribers (and external tails) observe
771/// the file, not the buffer.
772async fn append(
773    producer: &mut FileProducer,
774    key: &StreamKey,
775    payload: &[u8],
776) -> Result<(), StreamErr<FileErr>> {
777    producer.send_to(key, payload)?.await?;
778    producer.flush().await
779}
780
781/// The publish policy for [`FilePublisher`].
782///
783/// # Examples
784///
785/// ```
786/// use std::io;
787///
788/// use ruststream_sea_file::prelude::*;
789/// use serde::Serialize;
790///
791/// #[derive(Debug, Outgoing, Serialize)]
792/// struct Started {
793///     service: String,
794/// }
795///
796/// #[ruststream::app]
797/// fn app() -> impl App {
798///     RustStream::new(AppInfo::new("recorder", "0.1.0"))
799///         .with_broker(FileBroker::new("/tmp/recorder.ss"), |b| {
800///             b.after_startup(FilePublish, async move |publisher| -> io::Result<()> {
801///                 publisher
802///                     .message(&Started { service: "recorder".into() })
803///                     .to("lifecycle")
804///                     .publish()
805///                     .await
806///                     .map_err(io::Error::other)
807///             });
808///         })
809/// }
810/// ```
811#[derive(Debug, Clone, Copy, Default)]
812#[must_use]
813pub struct FilePublish;
814
815impl PublishPolicy<ConnectedFileBroker> for FilePublish {
816    type Live = FilePublisher;
817
818    fn pair(
819        self,
820        connected: &ConnectedFileBroker,
821    ) -> impl Future<Output = Result<Self::Live, PairError>> {
822        ready(Ok(connected.publisher()))
823    }
824
825    /// Names the stream key a publish through this policy appends under: the destination the
826    /// mount site resolved, which on this transport is the stream key itself.
827    #[cfg(feature = "asyncapi")]
828    fn channel_bindings(&self, channel: &str) -> Bindings {
829        channel_extension(channel)
830    }
831}
832
833impl DefaultPublish for ConnectedFileBroker {
834    type Policy = FilePublish;
835}
836
837/// The harness's view of the in-process transport: what it injects, what it reads back, and the
838/// coordinator it counts the in-flight deliveries with.
839///
840/// A message written under a stream key reaches every subscription reading that key, which is the
841/// equal-name rule the default [`routes`](TestableBroker::routes) answers. A replay reads it only
842/// while it is still short of the end of the file.
843///
844/// # Panics
845///
846/// `inject` and `published` panic on a broker connected with `connect`: the harness drives only
847/// the file `connect_in_process` produced. `inject` also panics on a stream key the file refuses.
848#[cfg(feature = "testing")]
849impl TestableBroker for ConnectedFileBroker {
850    fn install_coordinator(&self, coordinator: Coordinator) {
851        if let Link::InProcess(file) = &self.core.link {
852            file.install(coordinator);
853        }
854    }
855
856    fn inject(&self, message: OutgoingMessage<'_>) {
857        let file = self.memory_file("inject");
858        let key = StreamKey::new(message.name()).unwrap_or_else(|err| {
859            panic!(
860                "the injected message to {:?} is not one a stream file takes: {err}",
861                message.name()
862            )
863        });
864        file.append(
865            key,
866            wire::encode(message.headers(), message.payload(), false),
867        );
868    }
869
870    fn published(&self, name: &str) -> Vec<RawMessage> {
871        self.memory_file("published").published(name)
872    }
873}
874
875#[cfg(feature = "testing")]
876impl ConnectedFileBroker {
877    /// The in-memory file, which is all the harness drives.
878    fn memory_file(&self, what: &str) -> &MemoryFile {
879        match &self.core.link {
880            Link::InProcess(file) => file,
881            Link::File(_) => panic!(
882                "TestableBroker::{what} reached a broker connected with `connect`; the harness \
883                 drives the file `connect_in_process` produces"
884            ),
885        }
886    }
887}
888
889/// The publish policy of this form, under the name every form uses.
890///
891/// A mount site names the concept, never the transport: moving a service from one form to
892/// another changes the prelude it globs and leaves the composition root alone. The prefixed
893/// [`FilePublish`] stays at the crate root, for a service that mixes both forms.
894///
895/// # Examples
896///
897/// ```
898/// use ruststream_sea_file::file::prelude::*;
899/// use serde::{Deserialize, Serialize};
900///
901/// #[derive(Debug, Deserialize)]
902/// struct Order {
903///     id: u64,
904/// }
905///
906/// #[derive(Debug, Outgoing, Serialize)]
907/// struct Receipt {
908///     order: u64,
909/// }
910///
911/// #[subscriber(FileStream::new("orders"), reply("receipts"))]
912/// async fn confirm(order: &Order) -> Receipt {
913///     Receipt { order: order.id }
914/// }
915///
916/// #[ruststream::app]
917/// fn app() -> impl App {
918///     RustStream::new(AppInfo::new("orders", "0.1.0"))
919///         .with_broker(FileBroker::new("/tmp/orders.ss"), |b| {
920///             b.include(confirm).out_reply(Publish);
921///         })
922/// }
923/// ```
924pub use FilePublish as Publish;
925
926pub mod prelude {
927    //! The imports a service on a stream file writes every time, in one glob.
928    //!
929    //! The framework's prelude, this form's broker, subscription descriptor, position and seeker
930    //! types, the seeking capability traits and context keys, and [`Publish`].
931    //!
932    //! This is the routes-side vocabulary: a mount site globs it and names policies by concept.
933    //! A handler body globs `ruststream::prelude` instead and bounds an injected publisher by
934    //! the framework's capability traits, so the two vocabularies never meet in one file.
935    //!
936    //! # Examples
937    //!
938    //! ```
939    //! use ruststream_sea_file::file::prelude::*;
940    //! use serde::Deserialize;
941    //!
942    //! #[derive(Debug, Deserialize)]
943    //! struct Order {
944    //!     id: u64,
945    //! }
946    //!
947    //! #[subscriber(FileStream::new("orders"), start_at(FilePosition::beginning()))]
948    //! async fn handle(order: &Order, Ctx(seeker): Ctx<SeekHandle>) -> HandlerOutcome {
949    //!     if order.id == 0 && seeker.seek(FilePosition::end()).await.is_err() {
950    //!         return HandlerOutcome::retry();
951    //!     }
952    //!     HandlerOutcome::ack()
953    //! }
954    //!
955    //! #[ruststream::app]
956    //! fn app() -> impl App {
957    //!     RustStream::new(AppInfo::new("orders", "0.1.0"))
958    //!         .with_broker(FileBroker::new("/tmp/orders.ss"), |b| {
959    //!             b.include(handle);
960    //!         })
961    //! }
962    //! ```
963
964    pub use ruststream::prelude::*;
965    // `Seekable` is implemented here too, but on the subscriber the runtime consumes, never named
966    // by a service - do not add it.
967    pub use ruststream::{Positioned, Seeker};
968
969    pub use crate::file::{FileBroker, Publish};
970    pub use crate::{
971        FileBatchContext, FileContext, FilePosition, FileSeeker, FileStream, Position, SeekHandle,
972    };
973}
974
975#[cfg(test)]
976mod tests {
977    use std::time::Duration;
978
979    use futures::future::join_all;
980    use ruststream::{Broker as _, ConnectedBroker as _, OutgoingMessage, Publisher as _};
981    use sea_streamer_file::{FileErr, FileId, MessageSource, StreamMode};
982    use sea_streamer_types::{Buffer as _, Message as _, StreamKey};
983    use tokio::runtime::Runtime;
984    use tokio::time::timeout;
985
986    use super::FileBroker;
987
988    /// How long one step may take before the test calls it stuck: each is a local file write.
989    const STEP: Duration = Duration::from_secs(10);
990
991    /// A limit small enough for a few messages to reach it.
992    const LIMIT: u64 = 8 * 1024;
993
994    /// What one publish appends: its number, so the file can be checked message by message.
995    fn body(n: usize) -> Vec<u8> {
996        let mut bytes = format!("{n:06}").into_bytes();
997        bytes.resize(300, b'.');
998        bytes
999    }
1000
1001    /// Every message of `key` that is wholly in the file, by its number.
1002    async fn read_back(path: &str, key: &StreamKey) -> Vec<usize> {
1003        let mut source = MessageSource::new(FileId::new(path), StreamMode::Replay)
1004            .await
1005            .expect("the file opens for a replay");
1006        let mut found = Vec::new();
1007        loop {
1008            match source.next().await {
1009                Ok(msg) if msg.message.stream_key() == *key => {
1010                    let bytes = msg.message.message().as_bytes().to_vec();
1011                    let n = std::str::from_utf8(&bytes[..6]).expect("a number").parse();
1012                    found.push(n.expect("a number"));
1013                }
1014                Ok(_) => {}
1015                Err(FileErr::NotEnoughBytes | FileErr::StreamEnded) => return found,
1016                Err(e) => panic!("the file reads back: {e}"),
1017            }
1018        }
1019    }
1020
1021    /// Publishes `count` messages into a file capped at [`LIMIT`], `concurrent` at a time,
1022    /// spread over `brokers` brokers opened on the file, and returns the numbers of the publishes
1023    /// that reported success.
1024    async fn publish_past_the_limit(
1025        path: &str,
1026        count: usize,
1027        concurrent: usize,
1028        brokers: usize,
1029    ) -> Vec<usize> {
1030        let mut connected = Vec::new();
1031        let mut publishers = Vec::new();
1032        for _ in 0..brokers {
1033            let broker = FileBroker::new(path).size_limit(LIMIT);
1034            connected.push(broker.clone().connect().await.expect("connects"));
1035            publishers.push(broker.publisher());
1036        }
1037        let mut succeeded = Vec::new();
1038        for wave in (0..count).collect::<Vec<_>>().chunks(concurrent) {
1039            let results = join_all(wave.iter().map(|&n| {
1040                let publisher = &publishers[n % brokers];
1041                async move {
1042                    let bytes = body(n);
1043                    let publish =
1044                        publisher.publish(OutgoingMessage::new("orders", &bytes[..]), None);
1045                    let result = timeout(STEP, publish).await.unwrap_or_else(|_| {
1046                        panic!("publish {n} never returned ({concurrent} at a time)")
1047                    });
1048                    (n, result)
1049                }
1050            }))
1051            .await;
1052            succeeded.extend(
1053                results
1054                    .into_iter()
1055                    .filter(|(_, r)| r.is_ok())
1056                    .map(|(n, _)| n),
1057            );
1058        }
1059        for connected in connected {
1060            let ended = timeout(STEP, connected.shutdown()).await;
1061            assert!(
1062                ended.is_ok(),
1063                "the broker never shut down ({concurrent} at a time)"
1064            );
1065        }
1066        succeeded
1067    }
1068
1069    // The client caps a stream file, and once an append fails its writer takes no more. A
1070    // publish must still return, and one this crate reports as done must be one the file holds
1071    // whole: a reader of the file is the only place a message lives once the publish returned.
1072    #[test]
1073    fn a_publish_at_the_size_limit_returns_and_one_reported_done_is_whole_in_the_file() {
1074        // The client's producers live on one process-wide task, spawned on the first runtime
1075        // that touches them, so every case runs on this one.
1076        let rt = Runtime::new().expect("a runtime");
1077        rt.block_on(async {
1078            // One publish at a time, eight at once through one broker, and eight at once through
1079            // eight brokers opened on the one file, which share its writer.
1080            for (concurrent, brokers) in [(1, 1), (8, 1), (8, 8)] {
1081                let path = std::env::temp_dir()
1082                    .join(format!(
1083                        "ruststream-sea-limit-{}-{concurrent}-{brokers}.ss",
1084                        std::process::id()
1085                    ))
1086                    .to_string_lossy()
1087                    .into_owned();
1088                let _stale = std::fs::remove_file(&path);
1089                let succeeded = publish_past_the_limit(&path, 64, concurrent, brokers).await;
1090                let key = StreamKey::new("orders").expect("a stream key");
1091                let held = timeout(STEP, read_back(&path, &key)).await;
1092                let held = held.unwrap_or_else(|_| {
1093                    panic!("reading the file back never ended ({concurrent} at a time)")
1094                });
1095                std::fs::remove_file(&path).expect("the file is removed");
1096                assert!(
1097                    succeeded.len() < 64,
1098                    "the limit refused nothing ({concurrent} at a time)"
1099                );
1100                for n in &succeeded {
1101                    assert!(
1102                        held.contains(n),
1103                        "publish {n} reported success but the file does not hold it whole \
1104                         ({concurrent} at a time; reported {succeeded:?}, held {held:?})"
1105                    );
1106                }
1107            }
1108        });
1109    }
1110}