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}