Skip to main content

atuin_server_postgres/
lib.rs

1use std::collections::HashMap;
2
3use rand::Rng;
4
5use async_trait::async_trait;
6use atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};
7use atuin_server_database::models::{NewSession, NewUser, Session, User};
8use atuin_server_database::{Database, DbError, DbResult, DbSettings};
9use sqlx::postgres::PgPoolOptions;
10
11use tracing::instrument;
12use uuid::Uuid;
13use wrappers::{DbRecord, DbSession, DbUser};
14
15mod wrappers;
16
17const MIN_PG_VERSION: u32 = 14;
18
19#[derive(Clone)]
20pub struct Postgres {
21    pool: sqlx::Pool<sqlx::postgres::Postgres>,
22    /// Optional read replica pool for read-only queries
23    read_pool: Option<sqlx::Pool<sqlx::postgres::Postgres>>,
24}
25
26impl Postgres {
27    /// Returns the appropriate pool for read operations.
28    /// Uses read_pool if available, otherwise falls back to the primary pool.
29    fn read_pool(&self) -> &sqlx::Pool<sqlx::postgres::Postgres> {
30        self.read_pool.as_ref().unwrap_or(&self.pool)
31    }
32}
33
34#[async_trait]
35impl Database for Postgres {
36    async fn new(settings: &DbSettings) -> DbResult<Self> {
37        let pool = PgPoolOptions::new()
38            .max_connections(100)
39            .connect(settings.db_uri.as_str())
40            .await?;
41
42        // Call server_version_num to get the DB server's major version number
43        // The call returns None for servers older than 8.x.
44        let pg_major_version: u32 =
45            pool.acquire()
46                .await?
47                .server_version_num()
48                .ok_or(DbError::Other(eyre::Report::msg(
49                    "could not get PostgreSQL version",
50                )))?
51                / 10000;
52
53        if pg_major_version < MIN_PG_VERSION {
54            return Err(DbError::Other(eyre::Report::msg(format!(
55                "unsupported PostgreSQL version {pg_major_version}, minimum required is {MIN_PG_VERSION}"
56            ))));
57        }
58
59        sqlx::migrate!("./migrations")
60            .run(&pool)
61            .await
62            .map_err(|error| DbError::Other(error.into()))?;
63
64        // Create read replica pool if configured
65        let read_pool = if let Some(read_db_uri) = &settings.read_db_uri {
66            tracing::info!("Connecting to read replica database");
67            let read_pool = PgPoolOptions::new()
68                .max_connections(100)
69                .connect(read_db_uri.as_str())
70                .await?;
71
72            // Verify the read replica is also a supported PostgreSQL version
73            let read_pg_major_version: u32 = read_pool
74                .acquire()
75                .await?
76                .server_version_num()
77                .ok_or(DbError::Other(eyre::Report::msg(
78                    "could not get PostgreSQL version from read replica",
79                )))?
80                / 10000;
81
82            if read_pg_major_version < MIN_PG_VERSION {
83                return Err(DbError::Other(eyre::Report::msg(format!(
84                    "unsupported PostgreSQL version {read_pg_major_version} on read replica, minimum required is {MIN_PG_VERSION}"
85                ))));
86            }
87
88            Some(read_pool)
89        } else {
90            None
91        };
92
93        Ok(Self { pool, read_pool })
94    }
95
96    #[instrument(skip_all)]
97    async fn get_session(&self, token: &str) -> DbResult<Session> {
98        sqlx::query_as("select id, user_id, token from sessions where token = $1")
99            .bind(token)
100            .fetch_one(self.read_pool())
101            .await
102            .map_err(Into::into)
103            .map(|DbSession(session)| session)
104    }
105
106    #[instrument(skip_all)]
107    async fn get_user(&self, username: &str) -> DbResult<User> {
108        sqlx::query_as("select id, username, email, password from users where username = $1")
109            .bind(username)
110            .fetch_one(self.read_pool())
111            .await
112            .map_err(Into::into)
113            .map(|DbUser(user)| user)
114    }
115
116    #[instrument(skip_all)]
117    async fn get_session_user(&self, token: &str) -> DbResult<User> {
118        sqlx::query_as(
119            "select users.id, users.username, users.email, users.password from users
120            inner join sessions
121            on users.id = sessions.user_id
122            and sessions.token = $1",
123        )
124        .bind(token)
125        .fetch_one(self.read_pool())
126        .await
127        .map_err(Into::into)
128        .map(|DbUser(user)| user)
129    }
130
131    async fn delete_store(&self, user: &User) -> DbResult<()> {
132        let mut tx = self.pool.begin().await?;
133
134        sqlx::query(
135            "delete from store
136            where user_id = $1",
137        )
138        .bind(user.id)
139        .execute(&mut *tx)
140        .await?;
141
142        sqlx::query(
143            "delete from store_idx_cache
144            where user_id = $1",
145        )
146        .bind(user.id)
147        .execute(&mut *tx)
148        .await?;
149
150        tx.commit().await?;
151
152        Ok(())
153    }
154
155    #[instrument(skip_all)]
156    async fn delete_user(&self, u: &User) -> DbResult<()> {
157        sqlx::query("delete from sessions where user_id = $1")
158            .bind(u.id)
159            .execute(&self.pool)
160            .await?;
161
162        sqlx::query("delete from history where user_id = $1")
163            .bind(u.id)
164            .execute(&self.pool)
165            .await?;
166
167        sqlx::query("delete from store where user_id = $1")
168            .bind(u.id)
169            .execute(&self.pool)
170            .await?;
171
172        sqlx::query("delete from total_history_count_user where user_id = $1")
173            .bind(u.id)
174            .execute(&self.pool)
175            .await?;
176
177        sqlx::query("delete from users where id = $1")
178            .bind(u.id)
179            .execute(&self.pool)
180            .await?;
181
182        Ok(())
183    }
184
185    #[instrument(skip_all)]
186    async fn update_user_password(&self, user: &User) -> DbResult<()> {
187        sqlx::query(
188            "update users
189            set password = $1
190            where id = $2",
191        )
192        .bind(&user.password)
193        .bind(user.id)
194        .execute(&self.pool)
195        .await?;
196
197        Ok(())
198    }
199
200    #[instrument(skip_all)]
201    async fn add_user(&self, user: &NewUser) -> DbResult<i64> {
202        let email: &str = &user.email;
203        let username: &str = &user.username;
204        let password: &str = &user.password;
205
206        let res: (i64,) = sqlx::query_as(
207            "insert into users
208                (username, email, password)
209            values($1, $2, $3)
210            returning id",
211        )
212        .bind(username)
213        .bind(email)
214        .bind(password)
215        .fetch_one(&self.pool)
216        .await?;
217
218        Ok(res.0)
219    }
220
221    #[instrument(skip_all)]
222    async fn add_session(&self, session: &NewSession) -> DbResult<()> {
223        let token: &str = &session.token;
224
225        sqlx::query(
226            "insert into sessions
227                (user_id, token)
228            values($1, $2)",
229        )
230        .bind(session.user_id)
231        .bind(token)
232        .execute(&self.pool)
233        .await?;
234
235        Ok(())
236    }
237
238    #[instrument(skip_all)]
239    async fn get_user_session(&self, u: &User) -> DbResult<Session> {
240        sqlx::query_as("select id, user_id, token from sessions where user_id = $1")
241            .bind(u.id)
242            .fetch_one(self.read_pool())
243            .await
244            .map_err(Into::into)
245            .map(|DbSession(session)| session)
246    }
247
248    #[instrument(skip_all)]
249    async fn add_records(&self, user: &User, records: &[Record<EncryptedData>]) -> DbResult<()> {
250        let mut tx = self.pool.begin().await?;
251
252        // We won't have uploaded this data if it wasn't the max. Therefore, we can deduce the max
253        // idx without having to make further database queries. Doing the query on this small
254        // amount of data should be much, much faster.
255        //
256        // Worst case, say we get this wrong. We end up caching data that isn't actually the max
257        // idx, so clients upload again. The cache logic can be verified with a sql query anyway :)
258
259        let mut heads = HashMap::<(HostId, &str), u64>::new();
260
261        for i in records {
262            let id = atuin_common::utils::uuid_v7();
263
264            let result = sqlx::query(
265                "insert into store
266                    (id, client_id, host, idx, timestamp, version, tag, data, cek, user_id)
267                values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
268                on conflict do nothing
269                ",
270            )
271            .bind(id)
272            .bind(i.id)
273            .bind(i.host.id)
274            .bind(i.idx as i64)
275            .bind(i.timestamp as i64) // throwing away some data, but i64 is still big in terms of time
276            .bind(&i.version)
277            .bind(&i.tag)
278            .bind(&i.data.data)
279            .bind(&i.data.content_encryption_key)
280            .bind(user.id)
281            .execute(&mut *tx)
282            .await?;
283
284            // Only update heads if we actually inserted the record
285            if result.rows_affected() > 0 {
286                heads
287                    .entry((i.host.id, &i.tag))
288                    .and_modify(|e| {
289                        if i.idx > *e {
290                            *e = i.idx
291                        }
292                    })
293                    .or_insert(i.idx);
294            }
295        }
296
297        // we've built the map of heads for this push, so commit it to the database
298        for ((host, tag), idx) in heads {
299            sqlx::query(
300                "insert into store_idx_cache
301                    (user_id, host, tag, idx)
302                values ($1, $2, $3, $4)
303                on conflict(user_id, host, tag) do update set idx = greatest(store_idx_cache.idx, $4)
304                ",
305            )
306            .bind(user.id)
307            .bind(host)
308            .bind(tag)
309            .bind(idx as i64)
310            .execute(&mut *tx)
311            .await
312            ?;
313        }
314
315        tx.commit().await?;
316
317        Ok(())
318    }
319
320    #[instrument(skip_all)]
321    async fn next_records(
322        &self,
323        user: &User,
324        host: HostId,
325        tag: String,
326        start: Option<RecordIdx>,
327        count: u64,
328    ) -> DbResult<Vec<Record<EncryptedData>>> {
329        tracing::debug!("{:?} - {:?} - {:?}", host, tag, start);
330        let start = start.unwrap_or(0);
331
332        let records: Result<Vec<DbRecord>, DbError> = sqlx::query_as(
333            "select client_id, host, idx, timestamp, version, tag, data, cek from store
334                    where user_id = $1
335                    and tag = $2
336                    and host = $3
337                    and idx >= $4
338                    order by idx asc
339                    limit $5",
340        )
341        .bind(user.id)
342        .bind(tag.clone())
343        .bind(host)
344        .bind(start as i64)
345        .bind(count as i64)
346        .fetch_all(self.read_pool())
347        .await
348        .map_err(Into::into);
349
350        let ret = match records {
351            Ok(records) => {
352                let records: Vec<Record<EncryptedData>> = records
353                    .into_iter()
354                    .map(|f| {
355                        let record: Record<EncryptedData> = f.into();
356                        record
357                    })
358                    .collect();
359
360                records
361            }
362            Err(DbError::NotFound) => {
363                tracing::debug!("no records found in store: {:?}/{}", host, tag);
364                return Ok(vec![]);
365            }
366            Err(e) => return Err(e),
367        };
368
369        Ok(ret)
370    }
371
372    async fn status(&self, user: &User) -> DbResult<RecordStatus> {
373        const STATUS_SQL: &str =
374            "select host, tag, max(idx) from store where user_id = $1 group by host, tag";
375
376        // If IDX_CACHE_ROLLOUT is set, then we
377        // 1. Read the value of the var, use it as a % chance of using the cache
378        // 2. If we use the cache, just read from the cache table
379        // 3. If we don't use the cache, read from the store table
380        // IDX_CACHE_ROLLOUT should be between 0 and 100.
381
382        let idx_cache_rollout = std::env::var("IDX_CACHE_ROLLOUT").unwrap_or("0".to_string());
383        let idx_cache_rollout = idx_cache_rollout.parse::<f64>().unwrap_or(0.0);
384        let use_idx_cache = rand::thread_rng().gen_bool(idx_cache_rollout / 100.0);
385
386        let mut res: Vec<(Uuid, String, i64)> = if use_idx_cache {
387            tracing::debug!("using idx cache for user {}", user.id);
388            sqlx::query_as("select host, tag, idx from store_idx_cache where user_id = $1")
389                .bind(user.id)
390                .fetch_all(self.read_pool())
391                .await?
392        } else {
393            tracing::debug!("using aggregate query for user {}", user.id);
394            sqlx::query_as(STATUS_SQL)
395                .bind(user.id)
396                .fetch_all(self.read_pool())
397                .await?
398        };
399
400        res.sort();
401
402        let mut status = RecordStatus::new();
403
404        for i in res.iter() {
405            status.set_raw(HostId(i.0), i.1.clone(), i.2 as u64);
406        }
407
408        Ok(status)
409    }
410}