Skip to main content

atuin_server_postgres/
lib.rs

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