Skip to content

Commit 2f85523

Browse files
committed
Merge branch 'vacuum'
2 parents 65763a4 + 6594db1 commit 2f85523

4 files changed

Lines changed: 224 additions & 70 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
<!-- ## [Unreleased](https://github.com/equalitie/ouisync/compare/v0.10.0...master) -->
8+
## [Unreleased](https://github.com/equalitie/ouisync/compare/v0.10.0...master)
9+
10+
- Fix repository size not decreasing after deleting files
911

1012
## [v0.10.0](https://github.com/equalitie/ouisync/compare/v0.9.3...v0.10.0) - 2025-12-29
1113

lib/src/db/mod.rs

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ use tracing::Span;
1313
use deadlock::ExpectShortLifetime;
1414
use ref_cast::RefCast;
1515
use sqlx::{
16-
Row, SqlitePool, TransactionManager,
16+
ConnectOptions, Row, SqlitePool, TransactionManager,
1717
sqlite::{
18-
Sqlite, SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous,
19-
SqliteTransactionManager,
18+
Sqlite, SqliteAutoVacuum, SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions,
19+
SqliteSynchronous, SqliteTransactionManager,
2020
},
2121
};
2222
use std::{
@@ -48,6 +48,20 @@ pub struct Pool {
4848

4949
impl Pool {
5050
async fn create(conn_options: SqliteConnectOptions) -> Result<Self, sqlx::Error> {
51+
if fs::try_exists(conn_options.get_filename())
52+
.await
53+
.unwrap_or(false)
54+
{
55+
// Try to enable auto-vacuum, but if it fails [^1] it's not a critical failure as we can
56+
// keep using the db without auto-vacuum [^2]. Just log the error and keep going.
57+
//
58+
// [^1]: for example, because of low disk space
59+
// [^2]: with the downside that disk space won't be reclaimed after data deletion
60+
if let Err(error) = enable_auto_vacuum(conn_options.get_filename()).await {
61+
tracing::warn!(?error, "failed to enable auto vacuum");
62+
}
63+
}
64+
5165
let conn_options = conn_options
5266
.journal_mode(SqliteJournalMode::Wal)
5367
.synchronous(SqliteSynchronous::Normal)
@@ -63,7 +77,12 @@ impl Pool {
6377
let write = pool_options
6478
.clone()
6579
.max_connections(1)
66-
.connect_with(conn_options.clone().optimize_on_close(true, Some(1000)))
80+
.connect_with(
81+
conn_options
82+
.clone()
83+
.auto_vacuum(SqliteAutoVacuum::Full)
84+
.optimize_on_close(true, Some(1000)),
85+
)
6786
.await?;
6887

6988
let reads = pool_options
@@ -316,6 +335,18 @@ impl std::fmt::Debug for WriteTransaction {
316335

317336
impl_executor_by_deref!(WriteTransaction);
318337

338+
#[derive(Debug, Error)]
339+
pub enum Error {
340+
#[error("failed to create database directory")]
341+
CreateDirectory(#[source] io::Error),
342+
#[error("database already exists")]
343+
Exists,
344+
#[error("failed to open database")]
345+
Open(#[source] sqlx::Error),
346+
#[error("failed to execute database query")]
347+
Query(#[from] sqlx::Error),
348+
}
349+
319350
/// Creates a new database and opens a connection to it.
320351
pub(crate) async fn create(path: impl AsRef<Path>) -> Result<Pool, Error> {
321352
let path = path.as_ref();
@@ -386,16 +417,30 @@ pub(crate) const fn encode_u64(u: u64) -> i64 {
386417
u as i64
387418
}
388419

389-
#[derive(Debug, Error)]
390-
pub enum Error {
391-
#[error("failed to create database directory")]
392-
CreateDirectory(#[source] io::Error),
393-
#[error("database already exists")]
394-
Exists,
395-
#[error("failed to open database")]
396-
Open(#[source] sqlx::Error),
397-
#[error("failed to execute database query")]
398-
Query(#[from] sqlx::Error),
420+
// Enable auto-vacuum on the given database unless already enabled
421+
async fn enable_auto_vacuum(db_path: &Path) -> Result<(), Error> {
422+
let mut conn = SqliteConnectOptions::new()
423+
.filename(db_path)
424+
.connect()
425+
.await?;
426+
427+
let auto_vacuum: u32 = sqlx::query("PRAGMA auto_vacuum")
428+
.fetch_one(&mut conn)
429+
.await?
430+
.get(0);
431+
432+
if auto_vacuum != 0 {
433+
return Ok(());
434+
}
435+
436+
sqlx::query("PRAGMA auto_vacuum=FULL")
437+
.execute(&mut conn)
438+
.await?;
439+
440+
// Execute `VACUUM` command for the `auto_vacuum` pragma to take effect.
441+
sqlx::query("VACUUM").execute(&mut conn).await?;
442+
443+
Ok(())
399444
}
400445

401446
async fn get_pragma(conn: &mut Connection, name: &str) -> Result<u32, Error> {

lib/src/repository/tests.rs

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use tokio::{
1313
sync::broadcast::Receiver,
1414
time::{self, Duration, timeout},
1515
};
16+
use tokio_stream::wrappers::ReadDirStream;
1617
use tracing::instrument;
1718

1819
#[tokio::test(flavor = "multi_thread")]
@@ -1190,16 +1191,13 @@ async fn aux_db_files_are_deleted_on_close() {
11901191
test_utils::init_log();
11911192

11921193
let (temp_dir, repo) = setup().await;
1193-
11941194
repo.close().await.unwrap();
11951195

1196-
let mut read_dir = fs::read_dir(temp_dir.path()).await.unwrap();
1197-
let mut entries = Vec::new();
1198-
1199-
while let Some(entry) = read_dir.next_entry().await.unwrap() {
1200-
entries.push(entry.path());
1201-
}
1202-
1196+
let entries: Vec<_> = ReadDirStream::new(fs::read_dir(temp_dir.path()).await.unwrap())
1197+
.map_ok(|entry| entry.path())
1198+
.try_collect()
1199+
.await
1200+
.unwrap();
12031201
assert_eq!(entries, [temp_dir.path().join(DEFAULT_REPO_NAME)]);
12041202
}
12051203

@@ -1255,6 +1253,63 @@ async fn export() {
12551253
assert_eq!(dst_repo.access_mode(), AccessMode::Read);
12561254
}
12571255

1256+
#[tokio::test]
1257+
async fn repository_size_decreases_after_delete() {
1258+
let (_base_dir, repo_path) = {
1259+
let (base_dir, repo) = setup().await;
1260+
let repo_path = base_dir.path().join(DEFAULT_REPO_NAME);
1261+
repo.close().await.unwrap();
1262+
(base_dir, repo_path)
1263+
};
1264+
let file_size: u64 = 1024 * 1024;
1265+
1266+
let repo_size_initial = fs::metadata(&repo_path).await.unwrap().len();
1267+
tracing::info!(?repo_size_initial);
1268+
1269+
{
1270+
let repo = Repository::open(&RepositoryParams::new(&repo_path), None, AccessMode::Write)
1271+
.await
1272+
.unwrap();
1273+
let mut file = repo.create_file("data").await.unwrap();
1274+
write_random_data(&mut file, file_size as usize).await;
1275+
file.flush().await.unwrap();
1276+
repo.close().await.unwrap();
1277+
}
1278+
1279+
ensure_aux_db_files_are_deleted(&repo_path).await;
1280+
1281+
let repo_size_after_create = fs::metadata(&repo_path).await.unwrap().len();
1282+
tracing::info!(?repo_size_after_create);
1283+
assert!(
1284+
repo_size_after_create >= repo_size_initial + file_size,
1285+
"actual size: {}, expected min size: {}",
1286+
repo_size_after_create,
1287+
repo_size_initial + file_size
1288+
);
1289+
1290+
{
1291+
let repo = Repository::open(&RepositoryParams::new(&repo_path), None, AccessMode::Write)
1292+
.await
1293+
.unwrap();
1294+
repo.remove_entry("data").await.unwrap();
1295+
1296+
wait_for(&repo, async || repo.count_blocks().await.unwrap() <= 1).await;
1297+
1298+
repo.close().await.unwrap();
1299+
}
1300+
1301+
ensure_aux_db_files_are_deleted(&repo_path).await;
1302+
1303+
let repo_size_after_delete = fs::metadata(&repo_path).await.unwrap().len();
1304+
tracing::info!(?repo_size_after_delete);
1305+
assert!(
1306+
repo_size_after_delete <= repo_size_after_create - file_size,
1307+
"actual size: {}, expected max size: {}",
1308+
repo_size_after_delete,
1309+
repo_size_after_create - file_size
1310+
);
1311+
}
1312+
12581313
const DEFAULT_REPO_NAME: &str = "repo.db";
12591314

12601315
async fn setup() -> (TempDir, Repository) {
@@ -1311,6 +1366,19 @@ fn random_bytes(size: usize) -> Vec<u8> {
13111366
buffer
13121367
}
13131368

1369+
async fn write_random_data(file: &mut File, size: usize) {
1370+
let mut buffer = [0u8; 4 * 1024];
1371+
let mut remaining = size;
1372+
let mut rng = rand::thread_rng();
1373+
1374+
while remaining > 0 {
1375+
let chunk_size = buffer.len().min(remaining);
1376+
rng.fill(&mut buffer[..chunk_size]);
1377+
file.write_all(&buffer).await.unwrap();
1378+
remaining -= chunk_size;
1379+
}
1380+
}
1381+
13141382
async fn wait_for_notification(rx: &mut Receiver<Event>) {
13151383
match timeout(Duration::from_secs(5), rx.recv()).await {
13161384
Ok(Ok(_)) => (),
@@ -1341,3 +1409,31 @@ where
13411409
.await
13421410
.expect("timeout waiting for condition")
13431411
}
1412+
1413+
// HACK: Due to a [bug in sqlx][1], the db aux files are not always deleted after a repository is
1414+
// closed. As a workaround, this function reopens and closes the repository until the aux files are
1415+
// gone.
1416+
//
1417+
// [1]: https://github.com/launchbadge/sqlx/issues/3217
1418+
async fn ensure_aux_db_files_are_deleted(repo_path: &Path) {
1419+
let expected_files = [repo_path.to_owned()];
1420+
1421+
loop {
1422+
if ReadDirStream::new(fs::read_dir(repo_path.parent().unwrap()).await.unwrap())
1423+
.map_ok(|entry| entry.path())
1424+
.try_collect::<Vec<_>>()
1425+
.await
1426+
.unwrap()
1427+
== expected_files
1428+
{
1429+
break;
1430+
}
1431+
1432+
Repository::open(&RepositoryParams::new(repo_path), None, AccessMode::Blind)
1433+
.await
1434+
.unwrap()
1435+
.close()
1436+
.await
1437+
.unwrap();
1438+
}
1439+
}

0 commit comments

Comments
 (0)