Skip to content

Commit 42c1189

Browse files
committed
feat: implement database vacuuming.
This change attempts to resolve #2927 by introducing the ability to VACUUM the database by: - giving atuin-server-database the `vacuum` method. This simply calls sqlx::query("VACUUM"). I also added a test, as we're using sqlx & sqlite this test can be executed in memory. Sadly the same cannot happen for postgres, but the test is kind of trivial. - exposing a vacuum endpoint on the server. This handler calls vacuum. This can be useful for users who wish to manually vacuum a db, but this is not exposed via the CLI, instead... - adding `auto_vacuum_threshold` option to the server configuration. If `auto_vacuum_threshold` is > 0, then the server will increment the `delete_count` atomic counter, and when this counter reached the threshold it will call vacuum and reset the counter. The default for `auto_vacuum_threshold` is `0` meaning "never vacuum", but users can set this to a value which will come down to their preference, guided by: - How long they anticipate their server running for. For example setting it to a high-ish number like 1000 would probably be appropriate for a server running on a machine which has long uptimes. For self hosting for a single user, this might be a little long though, keeping in mind that if the server goes down (or the mahcine goes down) the counter will reset to 0. - For users who _really care_ about disk space and don't really care about latency on deletes, setting to `1` will will vacuum on every delete. I considered adding some kind of "recommended" number but I don't think there is a good number to unilaterally recommend for this.
1 parent 7137e4f commit 42c1189

File tree

10 files changed

+120
-5
lines changed

10 files changed

+120
-5
lines changed

crates/atuin-server-database/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ pub trait Database: Sized + Clone + Send + Sync + 'static {
142142

143143
async fn oldest_history(&self, user: &User) -> DbResult<History>;
144144

145+
async fn vacuum(&self) -> DbResult<()>;
146+
145147
#[instrument(skip_all)]
146148
async fn calendar(
147149
&self,

crates/atuin-server-postgres/src/lib.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,16 @@ impl Database for Postgres {
672672

673673
Ok(status)
674674
}
675+
676+
#[instrument(skip_all)]
677+
async fn vacuum(&self) -> DbResult<()> {
678+
sqlx::query("VACUUM")
679+
.execute(&self.pool)
680+
.await
681+
.map_err(fix_error)?;
682+
683+
Ok(())
684+
}
675685
}
676686

677687
fn into_utc(x: OffsetDateTime) -> PrimitiveDateTime {

crates/atuin-server-sqlite/src/lib.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,9 +544,40 @@ impl Database for Sqlite {
544544
.map_err(fix_error)
545545
.map(|DbHistory(h)| h)
546546
}
547+
548+
#[instrument(skip_all)]
549+
async fn vacuum(&self) -> DbResult<()> {
550+
sqlx::query("VACUUM")
551+
.execute(&self.pool)
552+
.await
553+
.map_err(fix_error)?;
554+
555+
Ok(())
556+
}
547557
}
548558

549559
fn into_utc(x: OffsetDateTime) -> PrimitiveDateTime {
550560
let x = x.to_offset(UtcOffset::UTC);
551561
PrimitiveDateTime::new(x.date(), x.time())
552562
}
563+
564+
#[cfg(test)]
565+
mod tests {
566+
use super::*;
567+
568+
#[sqlx::test]
569+
async fn test_vacuum() {
570+
let db_uri = "sqlite::memory:";
571+
let settings = DbSettings {
572+
db_uri: db_uri.to_string(),
573+
};
574+
575+
let db = Sqlite::new(&settings)
576+
.await
577+
.expect("failed to create database");
578+
579+
// VACUUM should succeed without errors
580+
let result = db.vacuum().await;
581+
assert!(result.is_ok(), "VACUUM should succeed");
582+
}
583+
}

crates/atuin-server/server.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,8 @@
3333
# enable = false
3434
# cert_path = ""
3535
# pkey_path = ""
36+
37+
# [vacuum]
38+
# Automatically vacuum the database after this many delete operations
39+
# Set to 0 to disable automatic vacuuming
40+
# auto_vacuum_threshold = 0

crates/atuin-server/src/handlers/history.rs

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,17 +106,38 @@ pub async fn delete<DB: Database>(
106106
state: State<AppState<DB>>,
107107
Json(req): Json<DeleteHistoryRequest>,
108108
) -> Result<Json<MessageResponse>, ErrorResponseStatus<'static>> {
109-
let db = &state.0.database;
109+
let State(AppState {
110+
database,
111+
settings,
112+
delete_count,
113+
}) = state;
110114

111115
// user_id is the ID of the history, as set by the user (the server has its own ID)
112-
let deleted = db.delete_history(&user, req.client_id).await;
116+
let deleted = database.delete_history(&user, req.client_id).await;
113117

114118
if let Err(e) = deleted {
115119
error!("failed to delete history: {}", e);
116120
return Err(ErrorResponse::reply("failed to delete history")
117121
.with_status(StatusCode::INTERNAL_SERVER_ERROR));
118122
}
119123

124+
// Trigger vacuum if threshold is met
125+
if settings.vacuum.auto_vacuum_threshold > 0 {
126+
use std::sync::atomic::Ordering;
127+
let count = delete_count.fetch_add(1, Ordering::Relaxed) + 1;
128+
129+
if count >= settings.vacuum.auto_vacuum_threshold {
130+
debug!("triggering automatic vacuum after {} deletes", count);
131+
tokio::spawn(async move {
132+
if let Err(e) = database.vacuum().await {
133+
error!("failed to vacuum database: {}", e);
134+
} else {
135+
delete_count.store(0, Ordering::Relaxed);
136+
}
137+
});
138+
}
139+
}
140+
120141
Ok(Json(MessageResponse {
121142
message: String::from("deleted OK"),
122143
}))
@@ -128,7 +149,9 @@ pub async fn add<DB: Database>(
128149
state: State<AppState<DB>>,
129150
Json(req): Json<Vec<AddHistoryRequest>>,
130151
) -> Result<(), ErrorResponseStatus<'static>> {
131-
let State(AppState { database, settings }) = state;
152+
let State(AppState {
153+
database, settings, ..
154+
}) = state;
132155

133156
debug!("request to add {} history items", req.len());
134157
counter!("atuin_history_uploaded", req.len() as u64);
@@ -235,3 +258,21 @@ pub async fn calendar<DB: Database>(
235258

236259
Ok(Json(focus))
237260
}
261+
262+
#[instrument(skip_all, fields(user.id = user.id))]
263+
pub async fn vacuum<DB: Database>(
264+
UserAuth(user): UserAuth,
265+
state: State<AppState<DB>>,
266+
) -> Result<Json<MessageResponse>, ErrorResponseStatus<'static>> {
267+
let db = &state.0.database;
268+
269+
if let Err(e) = db.vacuum().await {
270+
error!("failed to vacuum database: {}", e);
271+
return Err(ErrorResponse::reply("failed to vacuum database")
272+
.with_status(StatusCode::INTERNAL_SERVER_ERROR));
273+
}
274+
275+
Ok(Json(MessageResponse {
276+
message: String::from("vacuum completed successfully"),
277+
}))
278+
}

crates/atuin-server/src/handlers/v0/record.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ pub async fn post<DB: Database>(
1717
state: State<AppState<DB>>,
1818
Json(records): Json<Vec<Record<EncryptedData>>>,
1919
) -> Result<(), ErrorResponseStatus<'static>> {
20-
let State(AppState { database, settings }) = state;
20+
let State(AppState {
21+
database, settings, ..
22+
}) = state;
2123

2224
tracing::debug!(
2325
count = records.len(),
@@ -58,6 +60,7 @@ pub async fn index<DB: Database>(
5860
let State(AppState {
5961
database,
6062
settings: _,
63+
..
6164
}) = state;
6265

6366
let record_index = match database.status(&user).await {
@@ -92,6 +95,7 @@ pub async fn next<DB: Database>(
9295
let State(AppState {
9396
database,
9497
settings: _,
98+
..
9599
}) = state;
96100
let params = params.0;
97101

crates/atuin-server/src/handlers/v0/store.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pub async fn delete<DB: Database>(
2121
let State(AppState {
2222
database,
2323
settings: _,
24+
..
2425
}) = state;
2526

2627
if let Err(e) = database.delete_store(&user).await {

crates/atuin-server/src/router.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::sync::{Arc, atomic::AtomicU64};
2+
13
use async_trait::async_trait;
24
use atuin_common::api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ErrorResponse};
35
use axum::{
@@ -106,6 +108,7 @@ async fn semver(request: Request, next: Next) -> Response {
106108
pub struct AppState<DB: Database> {
107109
pub database: DB,
108110
pub settings: Settings,
111+
pub delete_count: Arc<AtomicU64>,
109112
}
110113

111114
pub fn router<DB: Database>(database: DB, settings: Settings) -> Router {
@@ -118,6 +121,7 @@ pub fn router<DB: Database>(database: DB, settings: Settings) -> Router {
118121
.route("/sync/status", get(handlers::status::status))
119122
.route("/history", post(handlers::history::add))
120123
.route("/history", delete(handlers::history::delete))
124+
.route("/history/vacuum", post(handlers::history::vacuum))
121125
.route("/user/:username", get(handlers::user::get))
122126
.route("/account", delete(handlers::user::delete))
123127
.route("/account/password", patch(handlers::user::change_password))
@@ -144,7 +148,11 @@ pub fn router<DB: Database>(database: DB, settings: Settings) -> Router {
144148
Router::new().nest(path, routes)
145149
}
146150
.fallback(teapot)
147-
.with_state(AppState { database, settings })
151+
.with_state(AppState {
152+
database,
153+
settings,
154+
delete_count: Arc::new(AtomicU64::new(0)),
155+
})
148156
.layer(
149157
ServiceBuilder::new()
150158
.layer(axum::middleware::from_fn(clacks_overhead))

crates/atuin-server/src/settings.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ impl Default for Metrics {
5353
}
5454
}
5555

56+
#[derive(Clone, Debug, Deserialize, Serialize)]
57+
#[derive(Default)]
58+
pub struct Vacuum {
59+
/// Automatically vacuum after delete operations when the number of deleted
60+
/// rows exceeds this threshold. Set to 0 to disable automatic vacuum on delete.
61+
/// The manual vacuum endpoint is always available regardless of this setting.
62+
pub auto_vacuum_threshold: u64,
63+
}
64+
65+
5666
#[derive(Clone, Debug, Deserialize, Serialize)]
5767
pub struct Settings {
5868
pub host: String,
@@ -67,6 +77,7 @@ pub struct Settings {
6777
pub metrics: Metrics,
6878
pub tls: Tls,
6979
pub mail: Mail,
80+
pub vacuum: Vacuum,
7081

7182
/// Advertise a version that is not what we are _actually_ running
7283
/// Many clients compare their version with api.atuin.sh, and if they differ, notify the user
@@ -109,6 +120,7 @@ impl Settings {
109120
.set_default("tls.enable", false)?
110121
.set_default("tls.cert_path", "")?
111122
.set_default("tls.pkey_path", "")?
123+
.set_default("vacuum.auto_vacuum_threshold", 0)?
112124
.add_source(
113125
Environment::with_prefix("atuin")
114126
.prefix_separator("_")

crates/atuin/tests/common/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ pub async fn start_server(path: &str) -> (String, oneshot::Sender<()>, JoinHandl
4141
tls: atuin_server::settings::Tls::default(),
4242
mail: atuin_server::settings::Mail::default(),
4343
fake_version: None,
44+
vacuum: atuin_server::settings::Vacuum::default(),
4445
};
4546

4647
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();

0 commit comments

Comments
 (0)