Skip to main content

sui_core/epoch/
consensus_store_pruner.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use consensus_config::Epoch;
5use mysten_metrics::spawn_logged_monitored_task;
6use prometheus::{
7    IntCounter, IntCounterVec, IntGauge, Registry, register_int_counter_vec_with_registry,
8    register_int_counter_with_registry, register_int_gauge_with_registry,
9};
10use std::fs;
11use std::path::PathBuf;
12use std::time::Duration;
13use tokio::{sync::mpsc, time::Instant};
14#[cfg(not(tidehunter))]
15use tracing::warn;
16use tracing::{error, info};
17use typed_store::rocks::safe_drop_db;
18
19struct Metrics {
20    last_pruned_consensus_db_epoch: IntGauge,
21    successfully_pruned_consensus_dbs: IntCounter,
22    error_pruning_consensus_dbs: IntCounterVec,
23}
24
25impl Metrics {
26    fn new(registry: &Registry) -> Self {
27        Self {
28            last_pruned_consensus_db_epoch: register_int_gauge_with_registry!(
29                "last_pruned_consensus_db_epoch",
30                "The last epoch for which the consensus store was pruned",
31                registry
32            )
33            .unwrap(),
34            successfully_pruned_consensus_dbs: register_int_counter_with_registry!(
35                "successfully_pruned_consensus_dbs",
36                "The number of consensus dbs successfully pruned",
37                registry
38            )
39            .unwrap(),
40            error_pruning_consensus_dbs: register_int_counter_vec_with_registry!(
41                "error_pruning_consensus_dbs",
42                "The number of errors encountered while pruning consensus dbs",
43                &["mode"],
44                registry
45            )
46            .unwrap(),
47        }
48    }
49}
50
51pub struct ConsensusStorePruner {
52    tx_remove: mpsc::Sender<Epoch>,
53    _handle: tokio::task::JoinHandle<()>,
54}
55
56impl ConsensusStorePruner {
57    pub fn new(
58        base_path: PathBuf,
59        epoch_retention: u64,
60        epoch_prune_period: Duration,
61        registry: &Registry,
62    ) -> Self {
63        let (tx_remove, mut rx_remove) = mpsc::channel(1);
64        let metrics = Metrics::new(registry);
65
66        let _handle = spawn_logged_monitored_task!(async {
67            info!(
68                "Starting consensus store pruner with epoch retention {epoch_retention} and prune period {epoch_prune_period:?}"
69            );
70
71            let mut timeout = tokio::time::interval_at(
72                Instant::now() + Duration::from_secs(60), // allow some time for the node to boot etc before attempting to prune
73                epoch_prune_period,
74            );
75
76            let mut latest_epoch = 0;
77            loop {
78                tokio::select! {
79                    _ = timeout.tick() => {
80                        Self::prune_old_epoch_data(&base_path, latest_epoch, epoch_retention, &metrics).await;
81                    }
82                    result = rx_remove.recv() => {
83                        if result.is_none() {
84                            info!("Closing consensus store pruner");
85                            break;
86                        }
87                        latest_epoch = result.unwrap();
88                        Self::prune_old_epoch_data(&base_path, latest_epoch, epoch_retention, &metrics).await;
89                    }
90                }
91            }
92        });
93
94        Self { tx_remove, _handle }
95    }
96
97    /// This method will remove all epoch data stores and directories that are older than the current epoch minus the epoch retention. The method ensures
98    /// that always the `current_epoch` data is retained.
99    pub async fn prune(&self, current_epoch: Epoch) {
100        let result = self.tx_remove.send(current_epoch).await;
101        if result.is_err() {
102            error!(
103                "Error sending message to data removal task for epoch {:?}",
104                current_epoch,
105            );
106        }
107    }
108
109    async fn prune_old_epoch_data(
110        storage_base_path: &PathBuf,
111        current_epoch: Epoch,
112        epoch_retention: u64,
113        metrics: &Metrics,
114    ) {
115        let drop_boundary = current_epoch.saturating_sub(epoch_retention);
116
117        info!(
118            "Consensus store prunning for current epoch {}. Will remove epochs < {:?}",
119            current_epoch, drop_boundary
120        );
121
122        // Get all the epoch stores in the base path directory
123        let files = match fs::read_dir(storage_base_path) {
124            Ok(f) => f,
125            Err(e) => {
126                error!(
127                    "Can not read the files in the storage path directory for epoch cleanup: {:?}",
128                    e
129                );
130                return;
131            }
132        };
133
134        // Look for any that are less than the drop boundary and drop
135        for file_res in files {
136            let f = match file_res {
137                Ok(f) => f,
138                Err(e) => {
139                    error!(
140                        "Error while cleaning up storage of previous epochs: {:?}",
141                        e
142                    );
143                    continue;
144                }
145            };
146
147            let name = f.file_name();
148            let file_epoch_string = match name.to_str() {
149                Some(f) => f,
150                None => continue,
151            };
152
153            let file_epoch = match file_epoch_string.to_owned().parse::<u64>() {
154                Ok(f) => f,
155                Err(e) => {
156                    error!(
157                        "Could not parse file \"{file_epoch_string}\" in storage path into epoch for cleanup: {:?}",
158                        e
159                    );
160                    continue;
161                }
162            };
163
164            if file_epoch < drop_boundary {
165                const WAIT_TIMEOUT: Duration = Duration::from_secs(5);
166                match safe_drop_db(f.path(), WAIT_TIMEOUT).await {
167                    Ok(()) => {
168                        info!(
169                            "Successfully pruned consensus epoch storage directory: {:?}",
170                            f.path()
171                        );
172                        let last_epoch = metrics.last_pruned_consensus_db_epoch.get();
173                        metrics
174                            .last_pruned_consensus_db_epoch
175                            .set(last_epoch.max(file_epoch as i64));
176                        metrics.successfully_pruned_consensus_dbs.inc();
177                    }
178                    Err(e) => {
179                        #[cfg(not(tidehunter))]
180                        {
181                            warn!(
182                                "Could not prune old consensus storage \"{:?}\" directory with safe approach. Will fallback to force delete: {:?}",
183                                f.path(),
184                                e
185                            );
186                            metrics
187                                .error_pruning_consensus_dbs
188                                .with_label_values(&["safe"])
189                                .inc();
190
191                            if let Err(err) = fs::remove_dir_all(f.path()) {
192                                error!(
193                                    "Could not prune old consensus storage \"{:?}\" directory with force delete: {:?}",
194                                    f.path(),
195                                    err
196                                );
197                                metrics
198                                    .error_pruning_consensus_dbs
199                                    .with_label_values(&["force"])
200                                    .inc();
201                            } else {
202                                info!(
203                                    "Successfully pruned consensus epoch storage directory with force delete: {:?}",
204                                    f.path()
205                                );
206                                let last_epoch = metrics.last_pruned_consensus_db_epoch.get();
207                                metrics
208                                    .last_pruned_consensus_db_epoch
209                                    .set(last_epoch.max(file_epoch as i64));
210                                metrics.successfully_pruned_consensus_dbs.inc();
211                            }
212                        }
213                        #[cfg(tidehunter)]
214                        {
215                            error!(
216                                "Could not prune old consensus storage \"{:?}\" directory: {:?}",
217                                f.path(),
218                                e
219                            );
220                            metrics
221                                .error_pruning_consensus_dbs
222                                .with_label_values(&["safe"])
223                                .inc();
224                        }
225                    }
226                }
227            }
228        }
229
230        info!(
231            "Completed old epoch data removal process for epoch {:?}",
232            current_epoch
233        );
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use crate::epoch::consensus_store_pruner::{ConsensusStorePruner, Metrics};
240    use prometheus::Registry;
241    use std::fs;
242    use tokio::time::sleep;
243
244    #[tokio::test]
245    async fn test_remove_old_epoch_data() {
246        telemetry_subscribers::init_for_testing();
247        let metrics = Metrics::new(&Registry::new());
248
249        {
250            // Epoch 0 should not be removed when it's current epoch.
251            let epoch_retention = 0;
252            let current_epoch = 0;
253
254            let base_directory = tempfile::tempdir().unwrap().keep();
255
256            create_epoch_directories(&base_directory, vec!["0", "other"]);
257
258            ConsensusStorePruner::prune_old_epoch_data(
259                &base_directory,
260                current_epoch,
261                epoch_retention,
262                &metrics,
263            )
264            .await;
265
266            let epochs_left = read_epoch_directories(&base_directory);
267
268            assert_eq!(epochs_left.len(), 1);
269            assert_eq!(epochs_left[0], 0);
270        }
271
272        {
273            // Every directory should be retained only for 1 epoch. We expect any epoch directories < 99 to be removed.
274            let epoch_retention = 1;
275            let current_epoch = 100;
276
277            let base_directory = tempfile::tempdir().unwrap().keep();
278
279            create_epoch_directories(&base_directory, vec!["97", "98", "99", "100", "other"]);
280
281            ConsensusStorePruner::prune_old_epoch_data(
282                &base_directory,
283                current_epoch,
284                epoch_retention,
285                &metrics,
286            )
287            .await;
288
289            let epochs_left = read_epoch_directories(&base_directory);
290
291            assert_eq!(epochs_left.len(), 2);
292            assert_eq!(epochs_left[0], 99);
293            assert_eq!(epochs_left[1], 100);
294        }
295
296        {
297            // Every directory should be retained only for 0 epochs. That means only the current epoch directory should be retained and everything else
298            // deleted.
299            let epoch_retention = 0;
300            let current_epoch = 100;
301
302            let base_directory = tempfile::tempdir().unwrap().keep();
303
304            create_epoch_directories(&base_directory, vec!["97", "98", "99", "100", "other"]);
305
306            ConsensusStorePruner::prune_old_epoch_data(
307                &base_directory,
308                current_epoch,
309                epoch_retention,
310                &metrics,
311            )
312            .await;
313
314            let epochs_left = read_epoch_directories(&base_directory);
315
316            assert_eq!(epochs_left.len(), 1);
317            assert_eq!(epochs_left[0], 100);
318        }
319    }
320
321    #[tokio::test(flavor = "current_thread")]
322    async fn test_consensus_store_pruner() {
323        let epoch_retention = 1;
324        let epoch_prune_period = std::time::Duration::from_millis(500);
325
326        let base_directory = tempfile::tempdir().unwrap().keep();
327
328        // We create some directories up to epoch 100
329        create_epoch_directories(&base_directory, vec!["97", "98", "99", "100", "other"]);
330
331        let pruner = ConsensusStorePruner::new(
332            base_directory.clone(),
333            epoch_retention,
334            epoch_prune_period,
335            &Registry::new(),
336        );
337
338        // We let the pruner run for a couple of times to prune the old directories. Since the default epoch of 0 is used no dirs should be pruned.
339        sleep(3 * epoch_prune_period).await;
340
341        // We expect the directories to be the same as before
342        let epoch_dirs = read_epoch_directories(&base_directory);
343        assert_eq!(epoch_dirs.len(), 4);
344
345        // Then we update the epoch and instruct to prune for current epoch = 100
346        pruner.prune(100).await;
347
348        // We let the pruner run and check again the directories - no directories of epoch < 99 should be left
349        sleep(2 * epoch_prune_period).await;
350
351        let epoch_dirs = read_epoch_directories(&base_directory);
352        assert_eq!(epoch_dirs.len(), 2);
353        assert_eq!(epoch_dirs[0], 99);
354        assert_eq!(epoch_dirs[1], 100);
355    }
356
357    fn create_epoch_directories(base_directory: &std::path::Path, epochs: Vec<&str>) {
358        for epoch in epochs {
359            let mut path = base_directory.to_path_buf();
360            path.push(epoch);
361            fs::create_dir(path).unwrap();
362        }
363    }
364
365    fn read_epoch_directories(base_directory: &std::path::Path) -> Vec<u64> {
366        let files = fs::read_dir(base_directory).unwrap();
367
368        let mut epochs = Vec::new();
369        for file_res in files {
370            let file_epoch_string = file_res.unwrap().file_name().to_str().unwrap().to_owned();
371            if let Ok(file_epoch) = file_epoch_string.parse::<u64>() {
372                epochs.push(file_epoch);
373            }
374        }
375
376        epochs.sort();
377        epochs
378    }
379}