Skip to main content

atuin_server_postgres/
lib.rs

1use std::collections::HashMap;
2use std::ops::Range;
3
4use rand::Rng;
5
6use async_trait::async_trait;
7use atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};
8use atuin_server_database::models::{History, NewHistory, NewSession, NewUser, Session, User};
9use atuin_server_database::{Database, DbError, DbResult, DbSettings, into_utc};
10use futures_util::TryStreamExt;
11use sqlx::Row;
12use sqlx::postgres::PgPoolOptions;
13
14use time::OffsetDateTime;
15use tracing::instrument;
16use uuid::Uuid;
17use wrappers::{DbHistory, DbRecord, DbSession, DbUser};
18
19mod wrappers;
20
21const MIN_PG_VERSION: u32 = 14;
22
23#[derive(Clone)]
24pub struct Postgres {
25    pool: sqlx::Pool<sqlx::postgres::Postgres>,
26    /// Optional read replica pool for read-only queries
27    read_pool: Option<sqlx::Pool<sqlx::postgres::Postgres>>,
28}
29
30impl Postgres {
31    /// Returns the appropriate pool for read operations.
32    /// Uses read_pool if available, otherwise falls back to the primary pool.
33    fn read_pool(&self) -> &sqlx::Pool<sqlx::postgres::Postgres> {
34        self.read_pool.as_ref().unwrap_or(&self.pool)
35    }
36}
37
38#[async_trait]
39impl Database for Postgres {
40    async fn new(settings: &DbSettings) -> DbResult<Self> {
41        let pool = PgPoolOptions::new()
42            .max_connections(100)
43            .connect(settings.db_uri.as_str())
44            .await?;
45
46        // Call server_version_num to get the DB server's major version number
47        // The call returns None for servers older than 8.x.
48        let pg_major_version: u32 =
49            pool.acquire()
50                .await?
51                .server_version_num()
52                .ok_or(DbError::Other(eyre::Report::msg(
53                    "could not get PostgreSQL version",
54                )))?
55                / 10000;
56
57        if pg_major_version < MIN_PG_VERSION {
58            return Err(DbError::Other(eyre::Report::msg(format!(
59                "unsupported PostgreSQL version {pg_major_version}, minimum required is {MIN_PG_VERSION}"
60            ))));
61        }
62
63        sqlx::migrate!("./migrations")
64            .run(&pool)
65            .await
66            .map_err(|error| DbError::Other(error.into()))?;
67
68        // Create read replica pool if configured
69        let read_pool = if let Some(read_db_uri) = &settings.read_db_uri {
70            tracing::info!("Connecting to read replica database");
71            let read_pool = PgPoolOptions::new()
72                .max_connections(100)
73                .connect(read_db_uri.as_str())
74                .await?;
75
76            // Verify the read replica is also a supported PostgreSQL version
77            let read_pg_major_version: u32 = read_pool
78                .acquire()
79                .await?
80                .server_version_num()
81                .ok_or(DbError::Other(eyre::Report::msg(
82                    "could not get PostgreSQL version from read replica",
83                )))?
84                / 10000;
85
86            if read_pg_major_version < MIN_PG_VERSION {
87                return Err(DbError::Other(eyre::Report::msg(format!(
88                    "unsupported PostgreSQL version {read_pg_major_version} on read replica, minimum required is {MIN_PG_VERSION}"
89                ))));
90            }
91
92            Some(read_pool)
93        } else {
94            None
95        };
96
97        Ok(Self { pool, read_pool })
98    }
99
100    #[instrument(skip_all)]
101    async fn get_session(&self, token: &str) -> DbResult<Session> {
102        sqlx::query_as("select id, user_id, token from sessions where token = $1")
103            .bind(token)
104            .fetch_one(self.read_pool())
105            .await
106            .map_err(Into::into)
107            .map(|DbSession(session)| session)
108    }
109
110    #[instrument(skip_all)]
111    async fn get_user(&self, username: &str) -> DbResult<User> {
112        sqlx::query_as("select id, username, email, password from users where username = $1")
113            .bind(username)
114            .fetch_one(self.read_pool())
115            .await
116            .map_err(Into::into)
117            .map(|DbUser(user)| user)
118    }
119
120    #[instrument(skip_all)]
121    async fn get_session_user(&self, token: &str) -> DbResult<User> {
122        sqlx::query_as(
123            "select users.id, users.username, users.email, users.password from users
124            inner join sessions
125            on users.id = sessions.user_id
126            and sessions.token = $1",
127        )
128        .bind(token)
129        .fetch_one(self.read_pool())
130        .await
131        .map_err(Into::into)
132        .map(|DbUser(user)| user)
133    }
134
135    #[instrument(skip_all)]
136    async fn count_history(&self, user: &User) -> DbResult<i64> {
137        // The cache is new, and the user might not yet have a cache value.
138        // They will have one as soon as they post up some new history, but handle that
139        // edge case.
140
141        let res: (i64,) = sqlx::query_as(
142            "select count(1) from history
143            where user_id = $1",
144        )
145        .bind(user.id)
146        .fetch_one(self.read_pool())
147        .await?;
148
149        Ok(res.0)
150    }
151
152    #[instrument(skip_all)]
153    async fn count_history_cached(&self, user: &User) -> DbResult<i64> {
154        let res: (i32,) = sqlx::query_as(
155            "select total from total_history_count_user
156            where user_id = $1",
157        )
158        .bind(user.id)
159        .fetch_one(self.read_pool())
160        .await?;
161
162        Ok(res.0 as i64)
163    }
164
165    async fn delete_store(&self, user: &User) -> DbResult<()> {
166        let mut tx = self.pool.begin().await?;
167
168        sqlx::query(
169            "delete from store
170            where user_id = $1",
171        )
172        .bind(user.id)
173        .execute(&mut *tx)
174        .await?;
175
176        sqlx::query(
177            "delete from store_idx_cache
178            where user_id = $1",
179        )
180        .bind(user.id)
181        .execute(&mut *tx)
182        .await?;
183
184        tx.commit().await?;
185
186        Ok(())
187    }
188
189    async fn delete_history(&self, user: &User, id: String) -> DbResult<()> {
190        sqlx::query(
191            "update history
192            set deleted_at = $3
193            where user_id = $1
194            and client_id = $2
195            and deleted_at is null", // don't just keep setting it
196        )
197        .bind(user.id)
198        .bind(id)
199        .bind(OffsetDateTime::now_utc())
200        .fetch_all(&self.pool)
201        .await?;
202
203        Ok(())
204    }
205
206    #[instrument(skip_all)]
207    async fn deleted_history(&self, user: &User) -> DbResult<Vec<String>> {
208        // The cache is new, and the user might not yet have a cache value.
209        // They will have one as soon as they post up some new history, but handle that
210        // edge case.
211
212        let res = sqlx::query(
213            "select client_id from history
214            where user_id = $1
215            and deleted_at is not null",
216        )
217        .bind(user.id)
218        .fetch_all(self.read_pool())
219        .await?;
220
221        let res = res
222            .iter()
223            .map(|row| row.get::<String, _>("client_id"))
224            .collect();
225
226        Ok(res)
227    }
228
229    #[instrument(skip_all)]
230    async fn count_history_range(
231        &self,
232        user: &User,
233        range: Range<OffsetDateTime>,
234    ) -> DbResult<i64> {
235        let res: (i64,) = sqlx::query_as(
236            "select count(1) from history
237            where user_id = $1
238            and timestamp >= $2::date
239            and timestamp < $3::date",
240        )
241        .bind(user.id)
242        .bind(into_utc(range.start))
243        .bind(into_utc(range.end))
244        .fetch_one(self.read_pool())
245        .await?;
246
247        Ok(res.0)
248    }
249
250    #[instrument(skip_all)]
251    async fn list_history(
252        &self,
253        user: &User,
254        created_after: OffsetDateTime,
255        since: OffsetDateTime,
256        host: &str,
257        page_size: i64,
258    ) -> DbResult<Vec<History>> {
259        let res = sqlx::query_as(
260            "select id, client_id, user_id, hostname, timestamp, data, created_at from history
261            where user_id = $1
262            and hostname != $2
263            and created_at >= $3
264            and timestamp >= $4
265            order by timestamp asc
266            limit $5",
267        )
268        .bind(user.id)
269        .bind(host)
270        .bind(into_utc(created_after))
271        .bind(into_utc(since))
272        .bind(page_size)
273        .fetch(self.read_pool())
274        .map_ok(|DbHistory(h)| h)
275        .try_collect()
276        .await?;
277
278        Ok(res)
279    }
280
281    #[instrument(skip_all)]
282    async fn add_history(&self, history: &[NewHistory]) -> DbResult<()> {
283        let mut tx = self.pool.begin().await?;
284
285        for i in history {
286            let client_id: &str = &i.client_id;
287            let hostname: &str = &i.hostname;
288            let data: &str = &i.data;
289
290            sqlx::query(
291                "insert into history
292                    (client_id, user_id, hostname, timestamp, data) 
293                values ($1, $2, $3, $4, $5)
294                on conflict do nothing
295                ",
296            )
297            .bind(client_id)
298            .bind(i.user_id)
299            .bind(hostname)
300            .bind(i.timestamp)
301            .bind(data)
302            .execute(&mut *tx)
303            .await?;
304        }
305
306        tx.commit().await?;
307
308        Ok(())
309    }
310
311    #[instrument(skip_all)]
312    async fn delete_user(&self, u: &User) -> DbResult<()> {
313        sqlx::query("delete from sessions where user_id = $1")
314            .bind(u.id)
315            .execute(&self.pool)
316            .await?;
317
318        sqlx::query("delete from history where user_id = $1")
319            .bind(u.id)
320            .execute(&self.pool)
321            .await?;
322
323        sqlx::query("delete from store where user_id = $1")
324            .bind(u.id)
325            .execute(&self.pool)
326            .await?;
327
328        sqlx::query("delete from total_history_count_user where user_id = $1")
329            .bind(u.id)
330            .execute(&self.pool)
331            .await?;
332
333        sqlx::query("delete from users where id = $1")
334            .bind(u.id)
335            .execute(&self.pool)
336            .await?;
337
338        Ok(())
339    }
340
341    #[instrument(skip_all)]
342    async fn update_user_password(&self, user: &User) -> DbResult<()> {
343        sqlx::query(
344            "update users
345            set password = $1
346            where id = $2",
347        )
348        .bind(&user.password)
349        .bind(user.id)
350        .execute(&self.pool)
351        .await?;
352
353        Ok(())
354    }
355
356    #[instrument(skip_all)]
357    async fn add_user(&self, user: &NewUser) -> DbResult<i64> {
358        let email: &str = &user.email;
359        let username: &str = &user.username;
360        let password: &str = &user.password;
361
362        let res: (i64,) = sqlx::query_as(
363            "insert into users
364                (username, email, password)
365            values($1, $2, $3)
366            returning id",
367        )
368        .bind(username)
369        .bind(email)
370        .bind(password)
371        .fetch_one(&self.pool)
372        .await?;
373
374        Ok(res.0)
375    }
376
377    #[instrument(skip_all)]
378    async fn add_session(&self, session: &NewSession) -> DbResult<()> {
379        let token: &str = &session.token;
380
381        sqlx::query(
382            "insert into sessions
383                (user_id, token)
384            values($1, $2)",
385        )
386        .bind(session.user_id)
387        .bind(token)
388        .execute(&self.pool)
389        .await?;
390
391        Ok(())
392    }
393
394    #[instrument(skip_all)]
395    async fn get_user_session(&self, u: &User) -> DbResult<Session> {
396        sqlx::query_as("select id, user_id, token from sessions where user_id = $1")
397            .bind(u.id)
398            .fetch_one(self.read_pool())
399            .await
400            .map_err(Into::into)
401            .map(|DbSession(session)| session)
402    }
403
404    #[instrument(skip_all)]
405    async fn oldest_history(&self, user: &User) -> DbResult<History> {
406        sqlx::query_as(
407            "select id, client_id, user_id, hostname, timestamp, data, created_at from history
408            where user_id = $1
409            order by timestamp asc
410            limit 1",
411        )
412        .bind(user.id)
413        .fetch_one(self.read_pool())
414        .await
415        .map_err(Into::into)
416        .map(|DbHistory(h)| h)
417    }
418
419    #[instrument(skip_all)]
420    async fn add_records(&self, user: &User, records: &[Record<EncryptedData>]) -> DbResult<()> {
421        let mut tx = self.pool.begin().await?;
422
423        // We won't have uploaded this data if it wasn't the max. Therefore, we can deduce the max
424        // idx without having to make further database queries. Doing the query on this small
425        // amount of data should be much, much faster.
426        //
427        // Worst case, say we get this wrong. We end up caching data that isn't actually the max
428        // idx, so clients upload again. The cache logic can be verified with a sql query anyway :)
429
430        let mut heads = HashMap::<(HostId, &str), u64>::new();
431
432        for i in records {
433            let id = atuin_common::utils::uuid_v7();
434
435            let result = sqlx::query(
436                "insert into store
437                    (id, client_id, host, idx, timestamp, version, tag, data, cek, user_id) 
438                values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
439                on conflict do nothing
440                ",
441            )
442            .bind(id)
443            .bind(i.id)
444            .bind(i.host.id)
445            .bind(i.idx as i64)
446            .bind(i.timestamp as i64) // throwing away some data, but i64 is still big in terms of time
447            .bind(&i.version)
448            .bind(&i.tag)
449            .bind(&i.data.data)
450            .bind(&i.data.content_encryption_key)
451            .bind(user.id)
452            .execute(&mut *tx)
453            .await?;
454
455            // Only update heads if we actually inserted the record
456            if result.rows_affected() > 0 {
457                heads
458                    .entry((i.host.id, &i.tag))
459                    .and_modify(|e| {
460                        if i.idx > *e {
461                            *e = i.idx
462                        }
463                    })
464                    .or_insert(i.idx);
465            }
466        }
467
468        // we've built the map of heads for this push, so commit it to the database
469        for ((host, tag), idx) in heads {
470            sqlx::query(
471                "insert into store_idx_cache
472                    (user_id, host, tag, idx) 
473                values ($1, $2, $3, $4)
474                on conflict(user_id, host, tag) do update set idx = greatest(store_idx_cache.idx, $4)
475                ",
476            )
477            .bind(user.id)
478            .bind(host)
479            .bind(tag)
480            .bind(idx as i64)
481            .execute(&mut *tx)
482            .await
483            ?;
484        }
485
486        tx.commit().await?;
487
488        Ok(())
489    }
490
491    #[instrument(skip_all)]
492    async fn next_records(
493        &self,
494        user: &User,
495        host: HostId,
496        tag: String,
497        start: Option<RecordIdx>,
498        count: u64,
499    ) -> DbResult<Vec<Record<EncryptedData>>> {
500        tracing::debug!("{:?} - {:?} - {:?}", host, tag, start);
501        let start = start.unwrap_or(0);
502
503        let records: Result<Vec<DbRecord>, DbError> = sqlx::query_as(
504            "select client_id, host, idx, timestamp, version, tag, data, cek from store
505                    where user_id = $1
506                    and tag = $2
507                    and host = $3
508                    and idx >= $4
509                    order by idx asc
510                    limit $5",
511        )
512        .bind(user.id)
513        .bind(tag.clone())
514        .bind(host)
515        .bind(start as i64)
516        .bind(count as i64)
517        .fetch_all(self.read_pool())
518        .await
519        .map_err(Into::into);
520
521        let ret = match records {
522            Ok(records) => {
523                let records: Vec<Record<EncryptedData>> = records
524                    .into_iter()
525                    .map(|f| {
526                        let record: Record<EncryptedData> = f.into();
527                        record
528                    })
529                    .collect();
530
531                records
532            }
533            Err(DbError::NotFound) => {
534                tracing::debug!("no records found in store: {:?}/{}", host, tag);
535                return Ok(vec![]);
536            }
537            Err(e) => return Err(e),
538        };
539
540        Ok(ret)
541    }
542
543    async fn status(&self, user: &User) -> DbResult<RecordStatus> {
544        const STATUS_SQL: &str =
545            "select host, tag, max(idx) from store where user_id = $1 group by host, tag";
546
547        // If IDX_CACHE_ROLLOUT is set, then we
548        // 1. Read the value of the var, use it as a % chance of using the cache
549        // 2. If we use the cache, just read from the cache table
550        // 3. If we don't use the cache, read from the store table
551        // IDX_CACHE_ROLLOUT should be between 0 and 100.
552
553        let idx_cache_rollout = std::env::var("IDX_CACHE_ROLLOUT").unwrap_or("0".to_string());
554        let idx_cache_rollout = idx_cache_rollout.parse::<f64>().unwrap_or(0.0);
555        let use_idx_cache = rand::thread_rng().gen_bool(idx_cache_rollout / 100.0);
556
557        let mut res: Vec<(Uuid, String, i64)> = if use_idx_cache {
558            tracing::debug!("using idx cache for user {}", user.id);
559            sqlx::query_as("select host, tag, idx from store_idx_cache where user_id = $1")
560                .bind(user.id)
561                .fetch_all(self.read_pool())
562                .await?
563        } else {
564            tracing::debug!("using aggregate query for user {}", user.id);
565            sqlx::query_as(STATUS_SQL)
566                .bind(user.id)
567                .fetch_all(self.read_pool())
568                .await?
569        };
570
571        res.sort();
572
573        let mut status = RecordStatus::new();
574
575        for i in res.iter() {
576            status.set_raw(HostId(i.0), i.1.clone(), i.2 as u64);
577        }
578
579        Ok(status)
580    }
581}