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}