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}