mithril_persistence/sqlite/
cleaner.rs1use std::collections::BTreeSet;
2
3use slog::{Logger, debug};
4
5use mithril_common::StdResult;
6use mithril_common::logging::LoggerExtensions;
7
8use crate::sqlite::SqliteConnection;
9
10#[derive(Eq, PartialEq, Copy, Clone, Ord, PartialOrd)]
15pub enum SqliteCleaningTask {
16 Vacuum,
22 WalCheckpointTruncate,
27 Optimize(OptimizeMode),
31}
32
33#[derive(Eq, PartialEq, Copy, Clone, Ord, PartialOrd)]
35pub enum OptimizeMode {
36 Default,
39 AllTables,
42}
43
44impl SqliteCleaningTask {
45 pub fn log_message(self: SqliteCleaningTask) -> &'static str {
47 match self {
48 SqliteCleaningTask::Vacuum => "Running `vacuum` on the SQLite database",
49 SqliteCleaningTask::WalCheckpointTruncate => {
50 "Running `wal_checkpoint(TRUNCATE)` on the SQLite database"
51 }
52 SqliteCleaningTask::Optimize(OptimizeMode::Default) => {
53 "Running `pragma optimize;` on the SQLite database"
54 }
55 SqliteCleaningTask::Optimize(OptimizeMode::AllTables) => {
56 "Running `pragma optimize=0x10002;` on the SQLite database"
57 }
58 }
59 }
60}
61
62pub struct SqliteCleaner<'a> {
65 connection: &'a SqliteConnection,
66 logger: Logger,
67 tasks: BTreeSet<SqliteCleaningTask>,
68}
69
70impl<'a> SqliteCleaner<'a> {
71 pub fn new(connection: &'a SqliteConnection) -> Self {
73 Self {
74 connection,
75 logger: Logger::root(slog::Discard, slog::o!()),
76 tasks: BTreeSet::new(),
77 }
78 }
79
80 pub fn with_logger(mut self, logger: Logger) -> Self {
82 self.logger = logger.new_with_component_name::<Self>();
83 self
84 }
85
86 pub fn with_tasks(mut self, tasks: &[SqliteCleaningTask]) -> Self {
94 for task in tasks {
95 self.tasks.insert(*task);
96 }
97 self
98 }
99
100 pub fn optimize(connection: &'a SqliteConnection, mode: OptimizeMode) -> StdResult<()> {
115 SqliteCleaner::new(connection)
116 .with_tasks(&[SqliteCleaningTask::Optimize(mode)])
117 .run()
118 }
119
120 pub fn run(self) -> StdResult<()> {
124 for task in &self.tasks {
125 debug!(self.logger, "{}", task.log_message());
126
127 match task {
128 SqliteCleaningTask::Vacuum => {
129 self.connection.execute("vacuum")?;
130 }
131 SqliteCleaningTask::WalCheckpointTruncate => {
132 self.connection.execute("PRAGMA wal_checkpoint(TRUNCATE);")?;
133 }
134 SqliteCleaningTask::Optimize(OptimizeMode::Default) => {
135 self.connection.execute("PRAGMA optimize;")?;
136 }
137 SqliteCleaningTask::Optimize(OptimizeMode::AllTables) => {
138 self.connection.execute("PRAGMA optimize=0x10002;")?;
139 }
140 }
141 }
142
143 if !self.tasks.contains(&SqliteCleaningTask::WalCheckpointTruncate) {
145 self.connection.execute("PRAGMA wal_checkpoint(PASSIVE);")?;
146 }
147
148 Ok(())
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use std::ops::Range;
155 use std::path::Path;
156
157 use mithril_common::current_function;
158 use mithril_common::test::TempDir;
159
160 use crate::sqlite::{ConnectionBuilder, ConnectionOptions, SqliteConnection};
161
162 use super::*;
163
164 fn add_test_table(connection: &SqliteConnection) {
165 connection
166 .execute("CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, text TEXT);")
167 .unwrap();
168 }
169
170 fn fill_test_table(connection: &SqliteConnection, ids: Range<u64>) {
171 connection
172 .execute(format!(
173 "INSERT INTO test (id, text) VALUES {}",
174 ids.map(|i| format!("({i}, 'some text to fill the db')"))
175 .collect::<Vec<String>>()
176 .join(", ")
177 ))
178 .unwrap();
179 }
180
181 fn delete_test_rows(connection: &SqliteConnection, ids: Range<u64>) {
182 connection
183 .execute(format!(
184 "DELETE FROM test WHERE id >= {} and id < {}",
185 ids.start, ids.end
186 ))
187 .unwrap();
188 }
189
190 fn prepare_db_for_vacuum(connection: &SqliteConnection) {
193 connection.execute("pragma auto_vacuum = none; vacuum;").unwrap();
195 add_test_table(connection);
196 fill_test_table(connection, 0..10_000);
197 connection.execute("PRAGMA wal_checkpoint(PASSIVE)").unwrap();
199 delete_test_rows(connection, 0..5_000);
200 connection.execute("PRAGMA wal_checkpoint(PASSIVE)").unwrap();
202 }
203
204 fn file_size(path: &Path) -> u64 {
205 path.metadata()
206 .unwrap_or_else(|_| panic!("Failed to read len of '{}'", path.display()))
207 .len()
208 }
209
210 fn tables_to_analyze(connection: &SqliteConnection) -> Vec<String> {
211 connection
213 .prepare("PRAGMA optimize(-1);")
214 .unwrap()
215 .iter()
216 .map(|row| row.unwrap().read::<&str, _>(0).to_string())
217 .collect()
218 }
219
220 #[test]
221 fn cleanup_empty_in_memory_db_should_not_crash() {
222 let connection = ConnectionBuilder::open_memory().build().unwrap();
223
224 SqliteCleaner::new(&connection)
225 .with_tasks(&[SqliteCleaningTask::Vacuum])
226 .run()
227 .expect("Vacuum should not fail");
228 SqliteCleaner::new(&connection)
229 .with_tasks(&[SqliteCleaningTask::WalCheckpointTruncate])
230 .run()
231 .expect("WalCheckpointTruncate should not fail");
232 }
233
234 #[test]
235 fn cleanup_empty_file_without_wal_db_should_not_crash() {
236 let db_path = TempDir::create("sqlite_cleaner", current_function!()).join("test.db");
237 let connection = ConnectionBuilder::open_file(&db_path).build().unwrap();
238
239 SqliteCleaner::new(&connection)
240 .with_tasks(&[SqliteCleaningTask::Vacuum])
241 .run()
242 .expect("Vacuum should not fail");
243 SqliteCleaner::new(&connection)
244 .with_tasks(&[SqliteCleaningTask::WalCheckpointTruncate])
245 .run()
246 .expect("WalCheckpointTruncate should not fail");
247 }
248
249 #[test]
250 fn test_vacuum() {
251 let db_dir = TempDir::create("sqlite_cleaner", current_function!());
252 let (db_path, db_wal_path) = (db_dir.join("test.db"), db_dir.join("test.db-wal"));
253 let connection = ConnectionBuilder::open_file(&db_path)
254 .with_options(&[ConnectionOptions::EnableWriteAheadLog])
255 .build()
256 .unwrap();
257 prepare_db_for_vacuum(&connection);
258
259 let db_initial_size = file_size(&db_path);
260 assert!(db_initial_size > 0);
261
262 SqliteCleaner::new(&connection)
263 .with_tasks(&[SqliteCleaningTask::Vacuum])
264 .run()
265 .unwrap();
266
267 let db_after_vacuum_size = file_size(&db_path);
268
269 assert!(
270 db_initial_size > db_after_vacuum_size,
271 "db size should have decreased (vacuum enabled)"
272 );
273 assert!(
274 file_size(&db_wal_path) > 0,
275 "db wal file should not have been truncated (truncate disabled)"
276 );
277 }
278
279 #[test]
280 fn test_truncate_wal() {
281 let db_dir = TempDir::create("sqlite_cleaner", current_function!());
282 let (db_path, db_wal_path) = (db_dir.join("test.db"), db_dir.join("test.db-wal"));
283 let connection = ConnectionBuilder::open_file(&db_path)
284 .with_options(&[ConnectionOptions::EnableWriteAheadLog])
285 .build()
286 .unwrap();
287
288 add_test_table(&connection);
291 fill_test_table(&connection, 0..10_000);
292 delete_test_rows(&connection, 0..10_000);
293
294 assert!(file_size(&db_wal_path) > 0);
295
296 SqliteCleaner::new(&connection)
297 .with_tasks(&[SqliteCleaningTask::WalCheckpointTruncate])
298 .run()
299 .unwrap();
300
301 assert_eq!(
302 file_size(&db_wal_path),
303 0,
304 "db wal file should have been truncated"
305 );
306 }
307
308 #[test]
309 fn test_vacuum_truncate_wal() {
310 let db_dir = TempDir::create("sqlite_cleaner", current_function!());
311 let (db_path, db_wal_path) = (db_dir.join("test.db"), db_dir.join("test.db-wal"));
312 let connection = ConnectionBuilder::open_file(&db_path)
313 .with_options(&[ConnectionOptions::EnableWriteAheadLog])
314 .build()
315 .unwrap();
316
317 add_test_table(&connection);
320 fill_test_table(&connection, 0..10_000);
321 delete_test_rows(&connection, 0..10_000);
322
323 assert!(file_size(&db_wal_path) > 0);
324
325 SqliteCleaner::new(&connection)
326 .with_tasks(&[SqliteCleaningTask::WalCheckpointTruncate, SqliteCleaningTask::Vacuum])
327 .run()
328 .unwrap();
329
330 assert_eq!(
331 file_size(&db_wal_path),
332 0,
333 "db wal file should have been truncated"
334 );
335 }
336
337 #[test]
338 fn test_optimize_default() {
339 let db_path = TempDir::create("sqlite_cleaner", current_function!()).join("test.db");
340 let connection = ConnectionBuilder::open_file(&db_path).build().unwrap();
341
342 add_test_table(&connection);
343 connection.execute("create index test_text on test(text)").unwrap();
345 fill_test_table(&connection, 0..10_000);
347
348 let analyze_candidate = tables_to_analyze(&connection);
349 assert!(!analyze_candidate.is_empty());
350
351 SqliteCleaner::optimize(&connection, OptimizeMode::Default).unwrap();
352
353 assert_eq!(Vec::<String>::new(), tables_to_analyze(&connection));
354 }
355
356 #[test]
357 fn test_optimize_all_tables() {
358 let db_path = TempDir::create("sqlite_cleaner", current_function!()).join("test.db");
359 let connection = ConnectionBuilder::open_file(&db_path).build().unwrap();
360
361 add_test_table(&connection);
362 connection.execute("create index test_text on test(text)").unwrap();
364 fill_test_table(&connection, 0..10_000);
366
367 let analyze_candidate = tables_to_analyze(&connection);
368 assert!(!analyze_candidate.is_empty());
369
370 SqliteCleaner::optimize(&connection, OptimizeMode::AllTables).unwrap();
371
372 assert_eq!(Vec::<String>::new(), tables_to_analyze(&connection));
373 }
374}