Skip to main content

turbojet_sql/
lib.rs

1//! SQL session storage for [Turbojet](turbojet): [`SqlStorage`] keeps each FIX session's sequence
2//! numbers and sent messages in SQLite or PostgreSQL, through [sqlx](https://docs.rs/sqlx).
3//!
4//! It suits deployments that can't rely on local disk, or that want session state next to their
5//! other data. Each session's state lives in a row of `turbojet_sessions`, and its stored
6//! messages in `turbojet_messages`; [`SqlStorage::migrate`] creates both.
7//!
8//! ```no_run
9//! # async fn example() -> std::io::Result<()> {
10//! use std::sync::Arc;
11//! use turbojet_sql::{SqlConfig, SqlStorage};
12//!
13//! let storage = SqlStorage::connect("sqlite://sessions.db?mode=rwc", SqlConfig::default()).await?;
14//! storage.migrate().await?;
15//! let registry = turbojet::SessionRegistry::new(Arc::new(storage));
16//! # Ok(())
17//! # }
18//! ```
19//!
20//! # How it stores
21//!
22//! A session's changes are kept in memory until the session commits them, once per batch of
23//! work; each commit is one transaction. Opening a session's log, committing and reading
24//! messages for a resend all run off the connection's task, as jobs the connection's driver
25//! awaits (see [`turbojet::store::Job`]), so a database round trip never blocks the runtime.
26//!
27//! Each session keeps at most [`SqlConfig::max_session_bytes`] of messages: past it, the oldest
28//! are deleted, and a resend that reaches back to them gap-fills them, as with
29//! [`DiskStorage`](turbojet::DiskStorage).
30//!
31//! # One gateway per session
32//!
33//! A gateway's registry lets one connection at a time run a session. Across gateways sharing a
34//! database, a lease does: opening a session's log takes its lease, for [`SqlConfig::lease`],
35//! and each commit renews it. A gateway whose lease has expired and been taken by another can't
36//! commit, so its session fails and disconnects rather than send under numbers the other is
37//! using. A session's commits come at least once per heartbeat interval while it's connected
38//! (each heartbeat is stored), so the lease must be longer than the longest heartbeat interval,
39//! with a margin for a slow database. Closing the log gives the lease up; a gateway that stops
40//! without closing it leaves it to expire.
41//!
42//! Leases compare times from each gateway's clock, so gateways sharing a database need clocks
43//! in step to well within the lease.
44//!
45//! # Backends
46//!
47//! Each is a feature: `sqlite` (the default, bundled) and `postgres`, with `tls` for TLS to
48//! PostgreSQL. The URL given to [`SqlStorage::connect`] picks one: `sqlite://path?mode=rwc` or
49//! `postgres://user:password@host/database`.
50
51mod log;
52mod schema;
53
54use std::io;
55use std::sync::Arc;
56use std::time::Duration;
57
58use sqlx::AnyPool;
59use sqlx::any::AnyPoolOptions;
60use turbojet::store::{Job, Opened, SessionId, SessionStorage};
61
62/// How a [`SqlStorage`] keeps sessions.
63#[derive(Debug, Clone)]
64#[non_exhaustive]
65pub struct SqlConfig {
66    /// How long a session's lease lasts from opening its log or its last commit, after which
67    /// another gateway may take the session over. Longer than the longest heartbeat interval,
68    /// with a margin: see the [crate documentation](crate#one-gateway-per-session).
69    pub lease: Duration,
70    /// Who holds the leases this store takes, shown to a gateway refused one: by default the
71    /// host name, the process ID and the time the store was configured.
72    pub holder: String,
73    /// The most bytes of messages each session keeps. Past it, the oldest go, down to 7/8 of it,
74    /// so that deleting them is rare.
75    pub max_session_bytes: u64,
76    /// SQLite only: whether each commit waits for the database file to reach the device
77    /// (`synchronous = FULL`), so state survives power loss, or only for the OS to have it
78    /// (`NORMAL`), so it survives a process crash. PostgreSQL's durability is the server's.
79    pub sync: bool,
80    /// The most connections the store opens to the database.
81    pub max_connections: u32,
82}
83
84impl SqlConfig {
85    /// The default byte budget for each session's messages, as
86    /// [`DiskStorage::DEFAULT_MAX_SESSION_BYTES`](turbojet::DiskStorage::DEFAULT_MAX_SESSION_BYTES).
87    pub const DEFAULT_MAX_SESSION_BYTES: u64 = 1 << 30;
88
89    /// The default lease: two minutes, comfortably longer than the usual 30-second heartbeat.
90    pub const DEFAULT_LEASE: Duration = Duration::from_secs(120);
91}
92
93impl Default for SqlConfig {
94    fn default() -> Self {
95        Self {
96            lease: Self::DEFAULT_LEASE,
97            holder: default_holder(),
98            max_session_bytes: Self::DEFAULT_MAX_SESSION_BYTES,
99            sync: true,
100            max_connections: 8,
101        }
102    }
103}
104
105/// Stores sessions in a SQL database: see the [crate documentation](crate).
106#[derive(Clone)]
107pub struct SqlStorage {
108    shared: Arc<Shared>,
109}
110
111impl std::fmt::Debug for SqlStorage {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.debug_struct("SqlStorage").finish_non_exhaustive()
114    }
115}
116
117/// What every log of a store shares.
118struct Shared {
119    pool: AnyPool,
120    config: SqlConfig,
121    backend: Backend,
122}
123
124/// The database behind a store, whose SQL differs in places.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126enum Backend {
127    Sqlite,
128    Postgres,
129}
130
131impl SqlStorage {
132    /// Connects to the database at `url`: `sqlite://path` (with `?mode=rwc` to create the file)
133    /// or `postgres://…`, whichever this build has the feature for.
134    ///
135    /// # Errors
136    ///
137    /// If the URL names a database this build doesn't support, or the database can't be reached.
138    pub async fn connect(url: &str, config: SqlConfig) -> io::Result<Self> {
139        let backend = Backend::of(url)?;
140        sqlx::any::install_default_drivers();
141        let sync = config.sync;
142        let pool = AnyPoolOptions::new()
143            .max_connections(config.max_connections)
144            .after_connect(move |connection, _| {
145                Box::pin(async move {
146                    if backend == Backend::Sqlite {
147                        schema::configure_sqlite(connection, sync).await?;
148                    }
149                    Ok(())
150                })
151            })
152            .connect(url)
153            .await
154            .map_err(log::database("connecting"))?;
155        Ok(Self { shared: Arc::new(Shared { pool, config, backend }) })
156    }
157
158    /// Creates the tables if they don't exist yet. Run it once before the first session opens,
159    /// or create them yourself from what it runs.
160    ///
161    /// # Errors
162    ///
163    /// If the database refuses the statements.
164    pub async fn migrate(&self) -> io::Result<()> {
165        schema::migrate(&self.shared.pool, self.shared.backend).await
166    }
167}
168
169impl SessionStorage for SqlStorage {
170    fn begin_open(&self, id: &SessionId) -> io::Result<Opened> {
171        Ok(Opened::Pending(Job::future(log::open(self.shared.clone(), id.clone()))))
172    }
173}
174
175impl Backend {
176    fn of(url: &str) -> io::Result<Self> {
177        let scheme = url.split_once(':').map_or(url, |(scheme, _)| scheme);
178        match scheme {
179            "sqlite" if cfg!(feature = "sqlite") => Ok(Self::Sqlite),
180            "postgres" | "postgresql" if cfg!(feature = "postgres") => Ok(Self::Postgres),
181            _ => Err(io::Error::new(
182                io::ErrorKind::Unsupported,
183                format!("turbojet-sql doesn't support '{scheme}' databases with the features it was built with"),
184            )),
185        }
186    }
187}
188
189/// The host, the process and the time: unique among gateways sharing a database, and readable
190/// in an error that names who holds a lease.
191fn default_holder() -> String {
192    let host = std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()).unwrap_or_else(|| "unknown-host".into());
193    let started = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
194    format!("{host}:{}:{started}", std::process::id())
195}