Skip to main content

rainier_session/
database.rs

1//! [`DatabaseSessionStore`] — sessions on the database you already have.
2
3use chrono::{DateTime, Duration, Utc};
4use rainier_database::{Criteria, Database, EntityRepository, Migrator, Model, Repository};
5use rainier_orm::Entity;
6use rainier_support::{BoxFuture, Error, Result};
7
8use crate::session::SessionData;
9use crate::store::SessionStore;
10
11/// A session row.
12#[derive(Entity, Clone, Debug, PartialEq)]
13#[orm(table = "rainier_sessions")]
14pub struct SessionRow {
15    /// The session id — the primary key, so a read is a point lookup.
16    #[orm(pk)]
17    pub id: String,
18
19    /// The bag, as JSON.
20    pub payload: String,
21
22    /// When this row stops being valid.
23    #[orm(index)]
24    pub expires_at: DateTime<Utc>,
25
26    /// When it was last written, for diagnostics.
27    pub updated_at: DateTime<Utc>,
28}
29
30impl Model for SessionRow {}
31
32/// Sessions in a table.
33///
34/// The right default for more than one instance: every process reads the same
35/// rows, so a user stays logged in wherever the load balancer sends them.
36pub struct DatabaseSessionStore {
37    rows: EntityRepository<SessionRow>,
38    lifetime: Duration,
39}
40
41impl DatabaseSessionStore {
42    /// A store over `db`, with two-hour sessions.
43    pub fn new(db: Database) -> Self {
44        Self { rows: EntityRepository::new(db), lifetime: Duration::hours(2) }
45    }
46
47    /// Sessions expiring after `lifetime`.
48    pub fn with_lifetime(mut self, lifetime: Duration) -> Self {
49        self.lifetime = lifetime;
50        self
51    }
52
53    /// The migration this driver needs.
54    ///
55    /// Merge it into the application's migrator:
56    ///
57    /// ```ignore
58    /// Migrator::new()
59    ///     .create_table::<User>("0001_create_users")
60    ///     .merge(DatabaseSessionStore::migrations())
61    /// ```
62    pub fn migrations() -> Migrator {
63        // The `down` is the drop that `create_table` implies. Rolling it back
64        // logs everyone out, which is the correct meaning of undoing it.
65        Migrator::new().create_table::<SessionRow>("rainier_session_0001_sessions")
66    }
67
68    /// Remove every expired row. Returns how many.
69    pub async fn prune(&self) -> Result<u64> {
70        self.rows.delete_matching(Criteria::new().where_lte("expires_at", Utc::now())).await
71    }
72}
73
74impl SessionStore for DatabaseSessionStore {
75    fn name(&self) -> &str {
76        "database"
77    }
78
79    fn read<'a>(&'a self, id: &'a str) -> BoxFuture<'a, Result<Option<SessionData>>> {
80        Box::pin(async move {
81            let Some(row) = self.rows.find(id.into()).await? else {
82                return Ok(None);
83            };
84
85            if row.expires_at <= Utc::now() {
86                // Expired: treat as absent and clear it out, so a stale row
87                // cannot come back if the clock or the lifetime changes.
88                self.rows.delete(id.into()).await?;
89                return Ok(None);
90            }
91
92            match serde_json::from_str(&row.payload) {
93                Ok(data) => Ok(Some(data)),
94                Err(e) => {
95                    // A row we cannot parse is a row from an older shape of
96                    // the application. Starting a fresh session logs the user
97                    // out; failing the request locks them out permanently,
98                    // with no way to recover but clearing their cookies.
99                    tracing::warn!(error = %e, "discarding an unreadable session row");
100                    self.rows.delete(id.into()).await?;
101                    Ok(None)
102                }
103            }
104        })
105    }
106
107    fn write<'a>(&'a self, id: &'a str, data: &'a SessionData) -> BoxFuture<'a, Result<()>> {
108        Box::pin(async move {
109            let now = Utc::now();
110            let row = SessionRow {
111                id: id.to_string(),
112                payload: serde_json::to_string(data)
113                    .map_err(|e| Error::internal(format!("the session will not serialise: {e}")))?,
114                expires_at: now + self.lifetime,
115                updated_at: now,
116            };
117
118            // Upsert rather than insert-or-update: a session is written on
119            // every request, and two concurrent requests for one session
120            // would otherwise race between the check and the insert.
121            self.rows.upsert(&row, &["id"], &["payload", "expires_at", "updated_at"]).await?;
122            Ok(())
123        })
124    }
125
126    fn destroy<'a>(&'a self, id: &'a str) -> BoxFuture<'a, Result<()>> {
127        Box::pin(async move {
128            self.rows.delete(id.into()).await?;
129            Ok(())
130        })
131    }
132
133    fn gc(&self) -> BoxFuture<'_, Result<u64>> {
134        Box::pin(async move { self.prune().await })
135    }
136}
137
138impl std::fmt::Debug for DatabaseSessionStore {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.debug_struct("DatabaseSessionStore").field("lifetime", &self.lifetime).finish()
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use rainier_database::testing::{fake_database, MemoryConnection};
148    use rainier_database::Dialect;
149
150    #[test]
151    fn the_migration_names_the_table() {
152        let migrator = DatabaseSessionStore::migrations();
153        assert_eq!(migrator.names(), vec!["rainier_session_0001_sessions"]);
154
155        let ddl = rainier_database::schema::schema_ddl::<SessionRow>(Dialect::Sqlite).join("\n");
156        assert!(ddl.contains("rainier_sessions"), "{ddl}");
157        assert!(ddl.contains("expires_at"), "{ddl}");
158    }
159
160    #[tokio::test]
161    async fn writing_upserts_rather_than_inserting() {
162        let (db, connection) = fake_database(MemoryConnection::new(Dialect::Sqlite));
163        let store = DatabaseSessionStore::new(db);
164
165        store.write("abc", &SessionData::default()).await.unwrap();
166
167        let sql = connection.last_statement().unwrap().to_uppercase();
168        assert!(
169            sql.contains("CONFLICT") || sql.contains("DUPLICATE") || sql.contains("MERGE"),
170            "two concurrent requests for one session must not race: {sql}"
171        );
172    }
173
174    #[tokio::test]
175    async fn an_unknown_session_reads_as_none() {
176        let (db, _) = fake_database(MemoryConnection::new(Dialect::Sqlite));
177        let store = DatabaseSessionStore::new(db);
178
179        assert!(store.read("nope").await.unwrap().is_none());
180    }
181
182    #[tokio::test]
183    async fn pruning_targets_expired_rows() {
184        let (db, connection) = fake_database(MemoryConnection::new(Dialect::Sqlite));
185        let store = DatabaseSessionStore::new(db);
186
187        store.prune().await.unwrap();
188
189        let sql = connection.last_statement().unwrap();
190        assert!(sql.to_uppercase().starts_with("DELETE"), "{sql}");
191        assert!(sql.contains("expires_at"), "{sql}");
192    }
193
194    #[test]
195    fn the_lifetime_is_configurable() {
196        let (db, _) = fake_database(MemoryConnection::new(Dialect::Sqlite));
197        let store = DatabaseSessionStore::new(db).with_lifetime(Duration::days(14));
198
199        assert_eq!(store.lifetime, Duration::days(14));
200    }
201}