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)]
64pub struct SqlConfig {
65    /// How long a session's lease lasts from opening its log or its last commit, after which
66    /// another gateway may take the session over. Longer than the longest heartbeat interval,
67    /// with a margin: see the [crate documentation](crate#one-gateway-per-session).
68    pub lease: Duration,
69    /// Who holds the leases this store takes, shown to a gateway refused one: by default the
70    /// host name, the process ID and the time the store was configured.
71    pub holder: String,
72    /// The most bytes of messages each session keeps. Past it, the oldest go, down to 7/8 of it,
73    /// so that deleting them is rare.
74    pub max_session_bytes: u64,
75    /// SQLite only: whether each commit waits for the database file to reach the device
76    /// (`synchronous = FULL`), so state survives power loss, or only for the OS to have it
77    /// (`NORMAL`), so it survives a process crash. PostgreSQL's durability is the server's.
78    pub sync: bool,
79    /// The most connections the store opens to the database.
80    pub max_connections: u32,
81}
82
83impl SqlConfig {
84    /// The default byte budget for each session's messages, as
85    /// [`DiskStorage::DEFAULT_MAX_SESSION_BYTES`](turbojet::DiskStorage::DEFAULT_MAX_SESSION_BYTES).
86    pub const DEFAULT_MAX_SESSION_BYTES: u64 = 1 << 30;
87
88    /// The default lease: two minutes, comfortably longer than the usual 30-second heartbeat.
89    pub const DEFAULT_LEASE: Duration = Duration::from_secs(120);
90}
91
92impl Default for SqlConfig {
93    fn default() -> Self {
94        Self {
95            lease: Self::DEFAULT_LEASE,
96            holder: default_holder(),
97            max_session_bytes: Self::DEFAULT_MAX_SESSION_BYTES,
98            sync: true,
99            max_connections: 8,
100        }
101    }
102}
103
104/// Stores sessions in a SQL database: see the [crate documentation](crate).
105#[derive(Clone)]
106pub struct SqlStorage {
107    shared: Arc<Shared>,
108}
109
110/// What every log of a store shares.
111struct Shared {
112    pool: AnyPool,
113    config: SqlConfig,
114    backend: Backend,
115}
116
117/// The database behind a store, whose SQL differs in places.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119enum Backend {
120    Sqlite,
121    Postgres,
122}
123
124impl SqlStorage {
125    /// Connects to the database at `url`: `sqlite://path` (with `?mode=rwc` to create the file)
126    /// or `postgres://…`, whichever this build has the feature for.
127    ///
128    /// # Errors
129    ///
130    /// If the URL names a database this build doesn't support, or the database can't be reached.
131    pub async fn connect(url: &str, config: SqlConfig) -> io::Result<Self> {
132        let backend = Backend::of(url)?;
133        sqlx::any::install_default_drivers();
134        let sync = config.sync;
135        let pool = AnyPoolOptions::new()
136            .max_connections(config.max_connections)
137            .after_connect(move |connection, _| {
138                Box::pin(async move {
139                    if backend == Backend::Sqlite {
140                        schema::configure_sqlite(connection, sync).await?;
141                    }
142                    Ok(())
143                })
144            })
145            .connect(url)
146            .await
147            .map_err(log::database("connecting"))?;
148        Ok(Self { shared: Arc::new(Shared { pool, config, backend }) })
149    }
150
151    /// Creates the tables if they don't exist yet. Run it once before the first session opens,
152    /// or create them yourself from what it runs.
153    ///
154    /// # Errors
155    ///
156    /// If the database refuses the statements.
157    pub async fn migrate(&self) -> io::Result<()> {
158        schema::migrate(&self.shared.pool, self.shared.backend).await
159    }
160}
161
162impl SessionStorage for SqlStorage {
163    fn begin_open(&self, id: &SessionId) -> io::Result<Opened> {
164        Ok(Opened::Pending(Job::future(log::open(self.shared.clone(), id.clone()))))
165    }
166}
167
168impl Backend {
169    fn of(url: &str) -> io::Result<Self> {
170        let scheme = url.split_once(':').map_or(url, |(scheme, _)| scheme);
171        match scheme {
172            "sqlite" if cfg!(feature = "sqlite") => Ok(Self::Sqlite),
173            "postgres" | "postgresql" if cfg!(feature = "postgres") => Ok(Self::Postgres),
174            _ => Err(io::Error::new(
175                io::ErrorKind::Unsupported,
176                format!("turbojet-sql doesn't support '{scheme}' databases with the features it was built with"),
177            )),
178        }
179    }
180}
181
182/// The host, the process and the time: unique among gateways sharing a database, and readable
183/// in an error that names who holds a lease.
184fn default_holder() -> String {
185    let host = std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()).unwrap_or_else(|| "unknown-host".into());
186    let started = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
187    format!("{host}:{}:{started}", std::process::id())
188}