xref: /wasmtime-44.0.1/crates/cache/src/config.rs (revision dfc4358d)
1 //! Module for configuring the cache system.
2 
3 use super::Worker;
4 use anyhow::{anyhow, bail, Context, Result};
5 use directories_next::ProjectDirs;
6 use log::{trace, warn};
7 use serde::{
8     de::{self, Deserializer},
9     Deserialize,
10 };
11 use std::fmt::Debug;
12 use std::fs;
13 use std::path::{Path, PathBuf};
14 use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
15 use std::sync::Arc;
16 use std::time::Duration;
17 
18 // wrapped, so we have named section in config,
19 // also, for possible future compatibility
20 #[derive(serde_derive::Deserialize, Debug)]
21 #[serde(deny_unknown_fields)]
22 struct Config {
23     cache: CacheConfig,
24 }
25 
26 /// Global configuration for how the cache is managed
27 #[derive(serde_derive::Deserialize, Debug, Clone)]
28 #[serde(deny_unknown_fields)]
29 pub struct CacheConfig {
30     enabled: bool,
31     directory: Option<PathBuf>,
32     #[serde(
33         default,
34         rename = "worker-event-queue-size",
35         deserialize_with = "deserialize_si_prefix"
36     )]
37     worker_event_queue_size: Option<u64>,
38     #[serde(rename = "baseline-compression-level")]
39     baseline_compression_level: Option<i32>,
40     #[serde(rename = "optimized-compression-level")]
41     optimized_compression_level: Option<i32>,
42     #[serde(
43         default,
44         rename = "optimized-compression-usage-counter-threshold",
45         deserialize_with = "deserialize_si_prefix"
46     )]
47     optimized_compression_usage_counter_threshold: Option<u64>,
48     #[serde(
49         default,
50         rename = "cleanup-interval",
51         deserialize_with = "deserialize_duration"
52     )]
53     cleanup_interval: Option<Duration>,
54     #[serde(
55         default,
56         rename = "optimizing-compression-task-timeout",
57         deserialize_with = "deserialize_duration"
58     )]
59     optimizing_compression_task_timeout: Option<Duration>,
60     #[serde(
61         default,
62         rename = "allowed-clock-drift-for-files-from-future",
63         deserialize_with = "deserialize_duration"
64     )]
65     allowed_clock_drift_for_files_from_future: Option<Duration>,
66     #[serde(
67         default,
68         rename = "file-count-soft-limit",
69         deserialize_with = "deserialize_si_prefix"
70     )]
71     file_count_soft_limit: Option<u64>,
72     #[serde(
73         default,
74         rename = "files-total-size-soft-limit",
75         deserialize_with = "deserialize_disk_space"
76     )]
77     files_total_size_soft_limit: Option<u64>,
78     #[serde(
79         default,
80         rename = "file-count-limit-percent-if-deleting",
81         deserialize_with = "deserialize_percent"
82     )]
83     file_count_limit_percent_if_deleting: Option<u8>,
84     #[serde(
85         default,
86         rename = "files-total-size-limit-percent-if-deleting",
87         deserialize_with = "deserialize_percent"
88     )]
89     files_total_size_limit_percent_if_deleting: Option<u8>,
90 
91     #[serde(skip)]
92     worker: Option<Worker>,
93     #[serde(skip)]
94     state: Arc<CacheState>,
95 }
96 
97 #[derive(Default, Debug)]
98 struct CacheState {
99     hits: AtomicUsize,
100     misses: AtomicUsize,
101 }
102 
103 /// Creates a new configuration file at specified path, or default path if None is passed.
104 /// Fails if file already exists.
105 pub fn create_new_config<P: AsRef<Path> + Debug>(config_file: Option<P>) -> Result<PathBuf> {
106     trace!("Creating new config file, path: {:?}", config_file);
107 
108     let config_file = match config_file {
109         Some(path) => path.as_ref().to_path_buf(),
110         None => default_config_path()?,
111     };
112 
113     if config_file.exists() {
114         bail!(
115             "Configuration file '{}' already exists.",
116             config_file.display()
117         );
118     }
119 
120     let parent_dir = config_file
121         .parent()
122         .ok_or_else(|| anyhow!("Invalid cache config path: {}", config_file.display()))?;
123 
124     fs::create_dir_all(parent_dir).with_context(|| {
125         format!(
126             "Failed to create config directory, config path: {}",
127             config_file.display(),
128         )
129     })?;
130 
131     let content = "\
132 # Comment out certain settings to use default values.
133 # For more settings, please refer to the documentation:
134 # https://bytecodealliance.github.io/wasmtime/cli-cache.html
135 
136 [cache]
137 enabled = true
138 ";
139 
140     fs::write(&config_file, content).with_context(|| {
141         format!(
142             "Failed to flush config to the disk, path: {}",
143             config_file.display(),
144         )
145     })?;
146 
147     Ok(config_file.to_path_buf())
148 }
149 
150 // permitted levels from: https://docs.rs/zstd/0.4.28+zstd.1.4.3/zstd/stream/write/struct.Encoder.html
151 const ZSTD_COMPRESSION_LEVELS: std::ops::RangeInclusive<i32> = 0..=21;
152 
153 // Default settings, you're welcome to tune them!
154 // TODO: what do we want to warn users about?
155 
156 // At the moment of writing, the modules couldn't depend on another,
157 // so we have at most one module per wasmtime instance
158 // if changed, update cli-cache.md
159 const DEFAULT_WORKER_EVENT_QUEUE_SIZE: u64 = 0x10;
160 const WORKER_EVENT_QUEUE_SIZE_WARNING_TRESHOLD: u64 = 3;
161 // should be quick and provide good enough compression
162 // if changed, update cli-cache.md
163 const DEFAULT_BASELINE_COMPRESSION_LEVEL: i32 = zstd::DEFAULT_COMPRESSION_LEVEL;
164 // should provide significantly better compression than baseline
165 // if changed, update cli-cache.md
166 const DEFAULT_OPTIMIZED_COMPRESSION_LEVEL: i32 = 20;
167 // shouldn't be to low to avoid recompressing too many files
168 // if changed, update cli-cache.md
169 const DEFAULT_OPTIMIZED_COMPRESSION_USAGE_COUNTER_THRESHOLD: u64 = 0x100;
170 // if changed, update cli-cache.md
171 const DEFAULT_CLEANUP_INTERVAL: Duration = Duration::from_secs(60 * 60);
172 // if changed, update cli-cache.md
173 const DEFAULT_OPTIMIZING_COMPRESSION_TASK_TIMEOUT: Duration = Duration::from_secs(30 * 60);
174 // the default assumes problems with timezone configuration on network share + some clock drift
175 // please notice 24 timezones = max 23h difference between some of them
176 // if changed, update cli-cache.md
177 const DEFAULT_ALLOWED_CLOCK_DRIFT_FOR_FILES_FROM_FUTURE: Duration =
178     Duration::from_secs(60 * 60 * 24);
179 // if changed, update cli-cache.md
180 const DEFAULT_FILE_COUNT_SOFT_LIMIT: u64 = 0x10_000;
181 // if changed, update cli-cache.md
182 const DEFAULT_FILES_TOTAL_SIZE_SOFT_LIMIT: u64 = 1024 * 1024 * 512;
183 // if changed, update cli-cache.md
184 const DEFAULT_FILE_COUNT_LIMIT_PERCENT_IF_DELETING: u8 = 70;
185 // if changed, update cli-cache.md
186 const DEFAULT_FILES_TOTAL_SIZE_LIMIT_PERCENT_IF_DELETING: u8 = 70;
187 
188 fn project_dirs() -> Option<ProjectDirs> {
189     ProjectDirs::from("", "BytecodeAlliance", "wasmtime")
190 }
191 
192 fn default_config_path() -> Result<PathBuf> {
193     match project_dirs() {
194         Some(dirs) => Ok(dirs.config_dir().join("config.toml")),
195         None => bail!("config file not specified and failed to get the default"),
196     }
197 }
198 
199 // Deserializers of our custom formats
200 // can be replaced with const generics later
201 macro_rules! generate_deserializer {
202     ($name:ident($numname:ident: $numty:ty, $unitname:ident: &str) -> $retty:ty {$body:expr}) => {
203         fn $name<'de, D>(deserializer: D) -> Result<$retty, D::Error>
204         where
205             D: Deserializer<'de>,
206         {
207             let text = Option::<String>::deserialize(deserializer)?;
208             let text = match text {
209                 None => return Ok(None),
210                 Some(text) => text,
211             };
212             let text = text.trim();
213             let split_point = text.find(|c: char| !c.is_numeric());
214             let (num, unit) = split_point.map_or_else(|| (text, ""), |p| text.split_at(p));
215             let deserialized = (|| {
216                 let $numname = num.parse::<$numty>().ok()?;
217                 let $unitname = unit.trim();
218                 $body
219             })();
220             if deserialized.is_some() {
221                 Ok(deserialized)
222             } else {
223                 Err(de::Error::custom(
224                     "Invalid value, please refer to the documentation",
225                 ))
226             }
227         }
228     };
229 }
230 
231 generate_deserializer!(deserialize_duration(num: u64, unit: &str) -> Option<Duration> {
232     match unit {
233         "s" => Some(Duration::from_secs(num)),
234         "m" => Some(Duration::from_secs(num * 60)),
235         "h" => Some(Duration::from_secs(num * 60 * 60)),
236         "d" => Some(Duration::from_secs(num * 60 * 60 * 24)),
237         _ => None,
238     }
239 });
240 
241 generate_deserializer!(deserialize_si_prefix(num: u64, unit: &str) -> Option<u64> {
242     match unit {
243         "" => Some(num),
244         "K" => num.checked_mul(1_000),
245         "M" => num.checked_mul(1_000_000),
246         "G" => num.checked_mul(1_000_000_000),
247         "T" => num.checked_mul(1_000_000_000_000),
248         "P" => num.checked_mul(1_000_000_000_000_000),
249         _ => None,
250     }
251 });
252 
253 generate_deserializer!(deserialize_disk_space(num: u64, unit: &str) -> Option<u64> {
254     match unit {
255         "" => Some(num),
256         "K" => num.checked_mul(1_000),
257         "Ki" => num.checked_mul(1u64 << 10),
258         "M" => num.checked_mul(1_000_000),
259         "Mi" => num.checked_mul(1u64 << 20),
260         "G" => num.checked_mul(1_000_000_000),
261         "Gi" => num.checked_mul(1u64 << 30),
262         "T" => num.checked_mul(1_000_000_000_000),
263         "Ti" => num.checked_mul(1u64 << 40),
264         "P" => num.checked_mul(1_000_000_000_000_000),
265         "Pi" => num.checked_mul(1u64 << 50),
266         _ => None,
267     }
268 });
269 
270 generate_deserializer!(deserialize_percent(num: u8, unit: &str) -> Option<u8> {
271     match unit {
272         "%" => Some(num),
273         _ => None,
274     }
275 });
276 
277 static CACHE_IMPROPER_CONFIG_ERROR_MSG: &str =
278     "Cache system should be enabled and all settings must be validated or defaulted";
279 
280 macro_rules! generate_setting_getter {
281     ($setting:ident: $setting_type:ty) => {
282         /// Returns `$setting`.
283         ///
284         /// Panics if the cache is disabled.
285         pub fn $setting(&self) -> $setting_type {
286             self.$setting.expect(CACHE_IMPROPER_CONFIG_ERROR_MSG)
287         }
288     };
289 }
290 
291 impl CacheConfig {
292     generate_setting_getter!(worker_event_queue_size: u64);
293     generate_setting_getter!(baseline_compression_level: i32);
294     generate_setting_getter!(optimized_compression_level: i32);
295     generate_setting_getter!(optimized_compression_usage_counter_threshold: u64);
296     generate_setting_getter!(cleanup_interval: Duration);
297     generate_setting_getter!(optimizing_compression_task_timeout: Duration);
298     generate_setting_getter!(allowed_clock_drift_for_files_from_future: Duration);
299     generate_setting_getter!(file_count_soft_limit: u64);
300     generate_setting_getter!(files_total_size_soft_limit: u64);
301     generate_setting_getter!(file_count_limit_percent_if_deleting: u8);
302     generate_setting_getter!(files_total_size_limit_percent_if_deleting: u8);
303 
304     /// Returns true if and only if the cache is enabled.
305     pub fn enabled(&self) -> bool {
306         self.enabled
307     }
308 
309     /// Returns path to the cache directory.
310     ///
311     /// Panics if the cache is disabled.
312     pub fn directory(&self) -> &PathBuf {
313         self.directory
314             .as_ref()
315             .expect(CACHE_IMPROPER_CONFIG_ERROR_MSG)
316     }
317 
318     /// Creates a new set of configuration which represents a disabled cache
319     pub fn new_cache_disabled() -> Self {
320         Self {
321             enabled: false,
322             directory: None,
323             worker_event_queue_size: None,
324             baseline_compression_level: None,
325             optimized_compression_level: None,
326             optimized_compression_usage_counter_threshold: None,
327             cleanup_interval: None,
328             optimizing_compression_task_timeout: None,
329             allowed_clock_drift_for_files_from_future: None,
330             file_count_soft_limit: None,
331             files_total_size_soft_limit: None,
332             file_count_limit_percent_if_deleting: None,
333             files_total_size_limit_percent_if_deleting: None,
334             worker: None,
335             state: Arc::new(CacheState::default()),
336         }
337     }
338 
339     fn new_cache_enabled_template() -> Self {
340         let mut conf = Self::new_cache_disabled();
341         conf.enabled = true;
342         conf
343     }
344 
345     /// Parses cache configuration from the file specified
346     pub fn from_file(config_file: Option<&Path>) -> Result<Self> {
347         let mut config = Self::load_and_parse_file(config_file)?;
348 
349         // validate values and fill in defaults
350         config.validate_directory_or_default()?;
351         config.validate_worker_event_queue_size_or_default();
352         config.validate_baseline_compression_level_or_default()?;
353         config.validate_optimized_compression_level_or_default()?;
354         config.validate_optimized_compression_usage_counter_threshold_or_default();
355         config.validate_cleanup_interval_or_default();
356         config.validate_optimizing_compression_task_timeout_or_default();
357         config.validate_allowed_clock_drift_for_files_from_future_or_default();
358         config.validate_file_count_soft_limit_or_default();
359         config.validate_files_total_size_soft_limit_or_default();
360         config.validate_file_count_limit_percent_if_deleting_or_default()?;
361         config.validate_files_total_size_limit_percent_if_deleting_or_default()?;
362         config.spawn_worker();
363 
364         Ok(config)
365     }
366 
367     fn spawn_worker(&mut self) {
368         if self.enabled {
369             self.worker = Some(Worker::start_new(self));
370         }
371     }
372 
373     pub(super) fn worker(&self) -> &Worker {
374         assert!(self.enabled);
375         self.worker.as_ref().unwrap()
376     }
377 
378     /// Returns the number of cache hits seen so far
379     pub fn cache_hits(&self) -> usize {
380         self.state.hits.load(SeqCst)
381     }
382 
383     /// Returns the number of cache misses seen so far
384     pub fn cache_misses(&self) -> usize {
385         self.state.misses.load(SeqCst)
386     }
387 
388     pub(crate) fn on_cache_get_async(&self, path: impl AsRef<Path>) {
389         self.state.hits.fetch_add(1, SeqCst);
390         self.worker().on_cache_get_async(path)
391     }
392 
393     pub(crate) fn on_cache_update_async(&self, path: impl AsRef<Path>) {
394         self.state.misses.fetch_add(1, SeqCst);
395         self.worker().on_cache_update_async(path)
396     }
397 
398     fn load_and_parse_file(config_file: Option<&Path>) -> Result<Self> {
399         // get config file path
400         let (config_file, user_custom_file) = match config_file {
401             Some(path) => (path.to_path_buf(), true),
402             None => (default_config_path()?, false),
403         };
404 
405         // read config, or use default one
406         let entity_exists = config_file.exists();
407         match (entity_exists, user_custom_file) {
408             (false, false) => Ok(Self::new_cache_enabled_template()),
409             _ => {
410                 let contents = fs::read_to_string(&config_file).context(format!(
411                     "failed to read config file: {}",
412                     config_file.display()
413                 ))?;
414                 let config = toml::from_str::<Config>(&contents).context(format!(
415                     "failed to parse config file: {}",
416                     config_file.display()
417                 ))?;
418                 Ok(config.cache)
419             }
420         }
421     }
422 
423     fn validate_directory_or_default(&mut self) -> Result<()> {
424         if self.directory.is_none() {
425             match project_dirs() {
426                 Some(proj_dirs) => self.directory = Some(proj_dirs.cache_dir().to_path_buf()),
427                 None => {
428                     bail!("Cache directory not specified and failed to get the default");
429                 }
430             }
431         }
432 
433         // On Windows, if we want long paths, we need '\\?\' prefix, but it doesn't work
434         // with relative paths. One way to get absolute path (the only one?) is to use
435         // fs::canonicalize, but it requires that given path exists. The extra advantage
436         // of this method is fact that the method prepends '\\?\' on Windows.
437         let cache_dir = self.directory.as_ref().unwrap();
438 
439         if !cache_dir.is_absolute() {
440             bail!(
441                 "Cache directory path has to be absolute, path: {}",
442                 cache_dir.display(),
443             );
444         }
445 
446         fs::create_dir_all(cache_dir).context(format!(
447             "failed to create cache directory: {}",
448             cache_dir.display()
449         ))?;
450         let canonical = fs::canonicalize(cache_dir).context(format!(
451             "failed to canonicalize cache directory: {}",
452             cache_dir.display()
453         ))?;
454         self.directory = Some(canonical);
455         Ok(())
456     }
457 
458     fn validate_worker_event_queue_size_or_default(&mut self) {
459         if self.worker_event_queue_size.is_none() {
460             self.worker_event_queue_size = Some(DEFAULT_WORKER_EVENT_QUEUE_SIZE);
461         }
462 
463         if self.worker_event_queue_size.unwrap() < WORKER_EVENT_QUEUE_SIZE_WARNING_TRESHOLD {
464             warn!("Detected small worker event queue size. Some messages might be lost.");
465         }
466     }
467 
468     fn validate_baseline_compression_level_or_default(&mut self) -> Result<()> {
469         if self.baseline_compression_level.is_none() {
470             self.baseline_compression_level = Some(DEFAULT_BASELINE_COMPRESSION_LEVEL);
471         }
472 
473         if !ZSTD_COMPRESSION_LEVELS.contains(&self.baseline_compression_level.unwrap()) {
474             bail!(
475                 "Invalid baseline compression level: {} not in {:#?}",
476                 self.baseline_compression_level.unwrap(),
477                 ZSTD_COMPRESSION_LEVELS
478             );
479         }
480         Ok(())
481     }
482 
483     // assumption: baseline compression level has been verified
484     fn validate_optimized_compression_level_or_default(&mut self) -> Result<()> {
485         if self.optimized_compression_level.is_none() {
486             self.optimized_compression_level = Some(DEFAULT_OPTIMIZED_COMPRESSION_LEVEL);
487         }
488 
489         let opt_lvl = self.optimized_compression_level.unwrap();
490         let base_lvl = self.baseline_compression_level.unwrap();
491 
492         if !ZSTD_COMPRESSION_LEVELS.contains(&opt_lvl) {
493             bail!(
494                 "Invalid optimized compression level: {} not in {:#?}",
495                 opt_lvl,
496                 ZSTD_COMPRESSION_LEVELS
497             );
498         }
499 
500         if opt_lvl < base_lvl {
501             bail!(
502                 "Invalid optimized compression level is lower than baseline: {} < {}",
503                 opt_lvl,
504                 base_lvl
505             );
506         }
507         Ok(())
508     }
509 
510     fn validate_optimized_compression_usage_counter_threshold_or_default(&mut self) {
511         if self.optimized_compression_usage_counter_threshold.is_none() {
512             self.optimized_compression_usage_counter_threshold =
513                 Some(DEFAULT_OPTIMIZED_COMPRESSION_USAGE_COUNTER_THRESHOLD);
514         }
515     }
516 
517     fn validate_cleanup_interval_or_default(&mut self) {
518         if self.cleanup_interval.is_none() {
519             self.cleanup_interval = Some(DEFAULT_CLEANUP_INTERVAL);
520         }
521     }
522 
523     fn validate_optimizing_compression_task_timeout_or_default(&mut self) {
524         if self.optimizing_compression_task_timeout.is_none() {
525             self.optimizing_compression_task_timeout =
526                 Some(DEFAULT_OPTIMIZING_COMPRESSION_TASK_TIMEOUT);
527         }
528     }
529 
530     fn validate_allowed_clock_drift_for_files_from_future_or_default(&mut self) {
531         if self.allowed_clock_drift_for_files_from_future.is_none() {
532             self.allowed_clock_drift_for_files_from_future =
533                 Some(DEFAULT_ALLOWED_CLOCK_DRIFT_FOR_FILES_FROM_FUTURE);
534         }
535     }
536 
537     fn validate_file_count_soft_limit_or_default(&mut self) {
538         if self.file_count_soft_limit.is_none() {
539             self.file_count_soft_limit = Some(DEFAULT_FILE_COUNT_SOFT_LIMIT);
540         }
541     }
542 
543     fn validate_files_total_size_soft_limit_or_default(&mut self) {
544         if self.files_total_size_soft_limit.is_none() {
545             self.files_total_size_soft_limit = Some(DEFAULT_FILES_TOTAL_SIZE_SOFT_LIMIT);
546         }
547     }
548 
549     fn validate_file_count_limit_percent_if_deleting_or_default(&mut self) -> Result<()> {
550         if self.file_count_limit_percent_if_deleting.is_none() {
551             self.file_count_limit_percent_if_deleting =
552                 Some(DEFAULT_FILE_COUNT_LIMIT_PERCENT_IF_DELETING);
553         }
554 
555         let percent = self.file_count_limit_percent_if_deleting.unwrap();
556         if percent > 100 {
557             bail!(
558                 "Invalid files count limit percent if deleting: {} not in range 0-100%",
559                 percent
560             );
561         }
562         Ok(())
563     }
564 
565     fn validate_files_total_size_limit_percent_if_deleting_or_default(&mut self) -> Result<()> {
566         if self.files_total_size_limit_percent_if_deleting.is_none() {
567             self.files_total_size_limit_percent_if_deleting =
568                 Some(DEFAULT_FILES_TOTAL_SIZE_LIMIT_PERCENT_IF_DELETING);
569         }
570 
571         let percent = self.files_total_size_limit_percent_if_deleting.unwrap();
572         if percent > 100 {
573             bail!(
574                 "Invalid files total size limit percent if deleting: {} not in range 0-100%",
575                 percent
576             );
577         }
578         Ok(())
579     }
580 }
581 
582 #[cfg(test)]
583 #[macro_use]
584 pub mod tests;
585