xref: /wasmtime-44.0.1/crates/cache/src/config.rs (revision b221fca7)
1 //! Module for configuring the cache system.
2 
3 use anyhow::{Context, Result, anyhow, bail};
4 use directories_next::ProjectDirs;
5 use log::{trace, warn};
6 use serde::{
7     Deserialize,
8     de::{self, Deserializer},
9 };
10 use std::fmt::Debug;
11 use std::fs;
12 use std::path::{Path, PathBuf};
13 use std::time::Duration;
14 
15 // wrapped, so we have named section in config,
16 // also, for possible future compatibility
17 #[derive(serde_derive::Deserialize, Debug)]
18 #[serde(deny_unknown_fields)]
19 struct Config {
20     cache: CacheConfig,
21 }
22 
23 /// Global configuration for how the cache is managed
24 #[derive(serde_derive::Deserialize, Debug, Clone)]
25 #[serde(deny_unknown_fields)]
26 pub struct CacheConfig {
27     directory: Option<PathBuf>,
28     #[serde(
29         default = "default_worker_event_queue_size",
30         rename = "worker-event-queue-size",
31         deserialize_with = "deserialize_si_prefix"
32     )]
33     worker_event_queue_size: u64,
34     #[serde(
35         default = "default_baseline_compression_level",
36         rename = "baseline-compression-level"
37     )]
38     baseline_compression_level: i32,
39     #[serde(
40         default = "default_optimized_compression_level",
41         rename = "optimized-compression-level"
42     )]
43     optimized_compression_level: i32,
44     #[serde(
45         default = "default_optimized_compression_usage_counter_threshold",
46         rename = "optimized-compression-usage-counter-threshold",
47         deserialize_with = "deserialize_si_prefix"
48     )]
49     optimized_compression_usage_counter_threshold: u64,
50     #[serde(
51         default = "default_cleanup_interval",
52         rename = "cleanup-interval",
53         deserialize_with = "deserialize_duration"
54     )]
55     cleanup_interval: Duration,
56     #[serde(
57         default = "default_optimizing_compression_task_timeout",
58         rename = "optimizing-compression-task-timeout",
59         deserialize_with = "deserialize_duration"
60     )]
61     optimizing_compression_task_timeout: Duration,
62     #[serde(
63         default = "default_allowed_clock_drift_for_files_from_future",
64         rename = "allowed-clock-drift-for-files-from-future",
65         deserialize_with = "deserialize_duration"
66     )]
67     allowed_clock_drift_for_files_from_future: Duration,
68     #[serde(
69         default = "default_file_count_soft_limit",
70         rename = "file-count-soft-limit",
71         deserialize_with = "deserialize_si_prefix"
72     )]
73     file_count_soft_limit: u64,
74     #[serde(
75         default = "default_files_total_size_soft_limit",
76         rename = "files-total-size-soft-limit",
77         deserialize_with = "deserialize_disk_space"
78     )]
79     files_total_size_soft_limit: u64,
80     #[serde(
81         default = "default_file_count_limit_percent_if_deleting",
82         rename = "file-count-limit-percent-if-deleting",
83         deserialize_with = "deserialize_percent"
84     )]
85     file_count_limit_percent_if_deleting: u8,
86     #[serde(
87         default = "default_files_total_size_limit_percent_if_deleting",
88         rename = "files-total-size-limit-percent-if-deleting",
89         deserialize_with = "deserialize_percent"
90     )]
91     files_total_size_limit_percent_if_deleting: u8,
92 }
93 
94 impl Default for CacheConfig {
95     fn default() -> Self {
96         Self {
97             directory: None,
98             worker_event_queue_size: default_worker_event_queue_size(),
99             baseline_compression_level: default_baseline_compression_level(),
100             optimized_compression_level: default_optimized_compression_level(),
101             optimized_compression_usage_counter_threshold:
102                 default_optimized_compression_usage_counter_threshold(),
103             cleanup_interval: default_cleanup_interval(),
104             optimizing_compression_task_timeout: default_optimizing_compression_task_timeout(),
105             allowed_clock_drift_for_files_from_future:
106                 default_allowed_clock_drift_for_files_from_future(),
107             file_count_soft_limit: default_file_count_soft_limit(),
108             files_total_size_soft_limit: default_files_total_size_soft_limit(),
109             file_count_limit_percent_if_deleting: default_file_count_limit_percent_if_deleting(),
110             files_total_size_limit_percent_if_deleting:
111                 default_files_total_size_limit_percent_if_deleting(),
112         }
113     }
114 }
115 
116 /// Creates a new configuration file at specified path, or default path if None is passed.
117 /// Fails if file already exists.
118 pub fn create_new_config<P: AsRef<Path> + Debug>(config_file: Option<P>) -> Result<PathBuf> {
119     trace!("Creating new config file, path: {:?}", config_file);
120 
121     let config_file = match config_file {
122         Some(path) => path.as_ref().to_path_buf(),
123         None => default_config_path()?,
124     };
125 
126     if config_file.exists() {
127         bail!(
128             "Configuration file '{}' already exists.",
129             config_file.display()
130         );
131     }
132 
133     let parent_dir = config_file
134         .parent()
135         .ok_or_else(|| anyhow!("Invalid cache config path: {}", config_file.display()))?;
136 
137     fs::create_dir_all(parent_dir).with_context(|| {
138         format!(
139             "Failed to create config directory, config path: {}",
140             config_file.display(),
141         )
142     })?;
143 
144     let content = "\
145 # Comment out certain settings to use default values.
146 # For more settings, please refer to the documentation:
147 # https://bytecodealliance.github.io/wasmtime/cli-cache.html
148 
149 [cache]
150 ";
151 
152     fs::write(&config_file, content).with_context(|| {
153         format!(
154             "Failed to flush config to the disk, path: {}",
155             config_file.display(),
156         )
157     })?;
158 
159     Ok(config_file.to_path_buf())
160 }
161 
162 // permitted levels from: https://docs.rs/zstd/0.4.28+zstd.1.4.3/zstd/stream/write/struct.Encoder.html
163 const ZSTD_COMPRESSION_LEVELS: std::ops::RangeInclusive<i32> = 0..=21;
164 
165 // Default settings, you're welcome to tune them!
166 // TODO: what do we want to warn users about?
167 
168 // At the moment of writing, the modules couldn't depend on another,
169 // so we have at most one module per wasmtime instance
170 // if changed, update cli-cache.md
171 const fn default_worker_event_queue_size() -> u64 {
172     0x10
173 }
174 const fn worker_event_queue_size_warning_threshold() -> u64 {
175     3
176 }
177 // should be quick and provide good enough compression
178 // if changed, update cli-cache.md
179 const fn default_baseline_compression_level() -> i32 {
180     zstd::DEFAULT_COMPRESSION_LEVEL
181 }
182 // should provide significantly better compression than baseline
183 // if changed, update cli-cache.md
184 const fn default_optimized_compression_level() -> i32 {
185     20
186 }
187 // shouldn't be to low to avoid recompressing too many files
188 // if changed, update cli-cache.md
189 const fn default_optimized_compression_usage_counter_threshold() -> u64 {
190     0x100
191 }
192 // if changed, update cli-cache.md
193 const fn default_cleanup_interval() -> Duration {
194     Duration::from_secs(60 * 60)
195 }
196 // if changed, update cli-cache.md
197 const fn default_optimizing_compression_task_timeout() -> Duration {
198     Duration::from_secs(30 * 60)
199 }
200 // the default assumes problems with timezone configuration on network share + some clock drift
201 // please notice 24 timezones = max 23h difference between some of them
202 // if changed, update cli-cache.md
203 const fn default_allowed_clock_drift_for_files_from_future() -> Duration {
204     Duration::from_secs(60 * 60 * 24)
205 }
206 // if changed, update cli-cache.md
207 const fn default_file_count_soft_limit() -> u64 {
208     0x10_000
209 }
210 // if changed, update cli-cache.md
211 const fn default_files_total_size_soft_limit() -> u64 {
212     1024 * 1024 * 512
213 }
214 // if changed, update cli-cache.md
215 const fn default_file_count_limit_percent_if_deleting() -> u8 {
216     70
217 }
218 // if changed, update cli-cache.md
219 const fn default_files_total_size_limit_percent_if_deleting() -> u8 {
220     70
221 }
222 
223 fn project_dirs() -> Option<ProjectDirs> {
224     ProjectDirs::from("", "BytecodeAlliance", "wasmtime")
225 }
226 
227 fn default_config_path() -> Result<PathBuf> {
228     match project_dirs() {
229         Some(dirs) => Ok(dirs.config_dir().join("config.toml")),
230         None => bail!("config file not specified and failed to get the default"),
231     }
232 }
233 
234 // Deserializers of our custom formats
235 // can be replaced with const generics later
236 macro_rules! generate_deserializer {
237     ($name:ident($numname:ident: $numty:ty, $unitname:ident: &str) -> $retty:ty {$body:expr}) => {
238         fn $name<'de, D>(deserializer: D) -> Result<$retty, D::Error>
239         where
240             D: Deserializer<'de>,
241         {
242             let text = String::deserialize(deserializer)?;
243             let text = text.trim();
244             let split_point = text.find(|c: char| !c.is_numeric());
245             let (num, unit) = split_point.map_or_else(|| (text, ""), |p| text.split_at(p));
246             let deserialized = (|| {
247                 let $numname = num.parse::<$numty>().ok()?;
248                 let $unitname = unit.trim();
249                 $body
250             })();
251             if let Some(deserialized) = deserialized {
252                 Ok(deserialized)
253             } else {
254                 Err(de::Error::custom(
255                     "Invalid value, please refer to the documentation",
256                 ))
257             }
258         }
259     };
260 }
261 
262 generate_deserializer!(deserialize_duration(num: u64, unit: &str) -> Duration {
263     match unit {
264         "s" => Some(Duration::from_secs(num)),
265         "m" => Some(Duration::from_secs(num * 60)),
266         "h" => Some(Duration::from_secs(num * 60 * 60)),
267         "d" => Some(Duration::from_secs(num * 60 * 60 * 24)),
268         _ => None,
269     }
270 });
271 
272 generate_deserializer!(deserialize_si_prefix(num: u64, unit: &str) -> u64 {
273     match unit {
274         "" => Some(num),
275         "K" => num.checked_mul(1_000),
276         "M" => num.checked_mul(1_000_000),
277         "G" => num.checked_mul(1_000_000_000),
278         "T" => num.checked_mul(1_000_000_000_000),
279         "P" => num.checked_mul(1_000_000_000_000_000),
280         _ => None,
281     }
282 });
283 
284 generate_deserializer!(deserialize_disk_space(num: u64, unit: &str) -> u64 {
285     match unit {
286         "" => Some(num),
287         "K" => num.checked_mul(1_000),
288         "Ki" => num.checked_mul(1u64 << 10),
289         "M" => num.checked_mul(1_000_000),
290         "Mi" => num.checked_mul(1u64 << 20),
291         "G" => num.checked_mul(1_000_000_000),
292         "Gi" => num.checked_mul(1u64 << 30),
293         "T" => num.checked_mul(1_000_000_000_000),
294         "Ti" => num.checked_mul(1u64 << 40),
295         "P" => num.checked_mul(1_000_000_000_000_000),
296         "Pi" => num.checked_mul(1u64 << 50),
297         _ => None,
298     }
299 });
300 
301 generate_deserializer!(deserialize_percent(num: u8, unit: &str) -> u8 {
302     match unit {
303         "%" => Some(num),
304         _ => None,
305     }
306 });
307 
308 static CACHE_IMPROPER_CONFIG_ERROR_MSG: &str =
309     "Cache system should be enabled and all settings must be validated or defaulted";
310 
311 macro_rules! generate_setting_getter {
312     ($setting:ident: $setting_type:ty) => {
313         #[doc = concat!("Returns ", "`", stringify!($setting), "`.")]
314         ///
315         /// Panics if the cache is disabled.
316         pub fn $setting(&self) -> $setting_type {
317             self.$setting
318         }
319     };
320 }
321 
322 impl CacheConfig {
323     /// Creates a new set of configuration which represents a disabled cache
324     pub fn new() -> Self {
325         Self::default()
326     }
327 
328     /// Loads cache configuration specified at `path`.
329     ///
330     /// This method will read the file specified by `path` on the filesystem and
331     /// attempt to load cache configuration from it. This method can also fail
332     /// due to I/O errors, misconfiguration, syntax errors, etc. For expected
333     /// syntax in the configuration file see the [documentation online][docs].
334     ///
335     /// Passing in `None` loads cache configuration from the system default path.
336     /// This is located, for example, on Unix at `$HOME/.config/wasmtime/config.toml`
337     /// and is typically created with the `wasmtime config new` command.
338     ///
339     /// # Errors
340     ///
341     /// This method can fail due to any error that happens when loading the file
342     /// pointed to by `path` and attempting to load the cache configuration.
343     ///
344     /// [docs]: https://bytecodealliance.github.io/wasmtime/cli-cache.html
345     pub fn from_file(config_file: Option<&Path>) -> Result<Self> {
346         let mut config = Self::load_and_parse_file(config_file)?;
347         config.validate()?;
348         Ok(config)
349     }
350 
351     fn load_and_parse_file(config_file: Option<&Path>) -> Result<Self> {
352         // get config file path
353         let (config_file, user_custom_file) = match config_file {
354             Some(path) => (path.to_path_buf(), true),
355             None => (default_config_path()?, false),
356         };
357 
358         // read config, or use default one
359         let entity_exists = config_file.exists();
360         match (entity_exists, user_custom_file) {
361             (false, false) => Ok(Self::new()),
362             _ => {
363                 let contents = fs::read_to_string(&config_file).context(format!(
364                     "failed to read config file: {}",
365                     config_file.display()
366                 ))?;
367                 let config = toml::from_str::<Config>(&contents).context(format!(
368                     "failed to parse config file: {}",
369                     config_file.display()
370                 ))?;
371                 Ok(config.cache)
372             }
373         }
374     }
375 
376     generate_setting_getter!(worker_event_queue_size: u64);
377     generate_setting_getter!(baseline_compression_level: i32);
378     generate_setting_getter!(optimized_compression_level: i32);
379     generate_setting_getter!(optimized_compression_usage_counter_threshold: u64);
380     generate_setting_getter!(cleanup_interval: Duration);
381     generate_setting_getter!(optimizing_compression_task_timeout: Duration);
382     generate_setting_getter!(allowed_clock_drift_for_files_from_future: Duration);
383     generate_setting_getter!(file_count_soft_limit: u64);
384     generate_setting_getter!(files_total_size_soft_limit: u64);
385     generate_setting_getter!(file_count_limit_percent_if_deleting: u8);
386     generate_setting_getter!(files_total_size_limit_percent_if_deleting: u8);
387 
388     /// Returns path to the cache directory.
389     ///
390     /// Panics if the cache is disabled.
391     pub fn directory(&self) -> &PathBuf {
392         self.directory
393             .as_ref()
394             .expect(CACHE_IMPROPER_CONFIG_ERROR_MSG)
395     }
396 
397     /// Specify where the cache directory is. Must be an absolute path.
398     pub fn with_directory(&mut self, directory: impl Into<PathBuf>) -> &mut Self {
399         self.directory = Some(directory.into());
400         self
401     }
402 
403     /// Size of cache worker event queue. If the queue is full, incoming cache usage events will be
404     /// dropped.
405     pub fn with_worker_event_queue_size(&mut self, size: u64) -> &mut Self {
406         self.worker_event_queue_size = size;
407         self
408     }
409 
410     /// Compression level used when a new cache file is being written by the cache system. Wasmtime
411     /// uses zstd compression.
412     pub fn with_baseline_compression_level(&mut self, level: i32) -> &mut Self {
413         self.baseline_compression_level = level;
414         self
415     }
416 
417     /// Compression level used when the cache worker decides to recompress a cache file. Wasmtime
418     /// uses zstd compression.
419     pub fn with_optimized_compression_level(&mut self, level: i32) -> &mut Self {
420         self.optimized_compression_level = level;
421         self
422     }
423 
424     /// One of the conditions for the cache worker to recompress a cache file is to have usage
425     /// count of the file exceeding this threshold.
426     pub fn with_optimized_compression_usage_counter_threshold(
427         &mut self,
428         threshold: u64,
429     ) -> &mut Self {
430         self.optimized_compression_usage_counter_threshold = threshold;
431         self
432     }
433 
434     /// When the cache worker is notified about a cache file being updated by the cache system and
435     /// this interval has already passed since last cleaning up, the worker will attempt a new
436     /// cleanup.
437     pub fn with_cleanup_interval(&mut self, interval: Duration) -> &mut Self {
438         self.cleanup_interval = interval;
439         self
440     }
441 
442     /// When the cache worker decides to recompress a cache file, it makes sure that no other
443     /// worker has started the task for this file within the last
444     /// optimizing-compression-task-timeout interval. If some worker has started working on it,
445     /// other workers are skipping this task.
446     pub fn with_optimizing_compression_task_timeout(&mut self, timeout: Duration) -> &mut Self {
447         self.optimizing_compression_task_timeout = timeout;
448         self
449     }
450 
451     /// ### Locks
452     ///
453     /// When the cache worker attempts acquiring a lock for some task, it checks if some other
454     /// worker has already acquired such a lock. To be fault tolerant and eventually execute every
455     /// task, the locks expire after some interval. However, because of clock drifts and different
456     /// timezones, it would happen that some lock was created in the future. This setting defines a
457     /// tolerance limit for these locks. If the time has been changed in the system (i.e. two years
458     /// backwards), the cache system should still work properly. Thus, these locks will be treated
459     /// as expired (assuming the tolerance is not too big).
460     ///
461     /// ### Cache files
462     ///
463     /// Similarly to the locks, the cache files or their metadata might have modification time in
464     /// distant future. The cache system tries to keep these files as long as possible. If the
465     /// limits are not reached, the cache files will not be deleted. Otherwise, they will be
466     /// treated as the oldest files, so they might survive. If the user actually uses the cache
467     /// file, the modification time will be updated.
468     pub fn with_allowed_clock_drift_for_files_from_future(&mut self, drift: Duration) -> &mut Self {
469         self.allowed_clock_drift_for_files_from_future = drift;
470         self
471     }
472 
473     /// Soft limit for the file count in the cache directory.
474     ///
475     /// This doesn't include files with metadata. To learn more, please refer to the cache system
476     /// section.
477     pub fn with_file_count_soft_limit(&mut self, limit: u64) -> &mut Self {
478         self.file_count_soft_limit = limit;
479         self
480     }
481 
482     /// Soft limit for the total size* of files in the cache directory.
483     ///
484     /// This doesn't include files with metadata. To learn more, please refer to the cache system
485     /// section.
486     ///
487     /// *this is the file size, not the space physically occupied on the disk.
488     pub fn with_files_total_size_soft_limit(&mut self, limit: u64) -> &mut Self {
489         self.files_total_size_soft_limit = limit;
490         self
491     }
492 
493     /// If file-count-soft-limit is exceeded and the cache worker performs the cleanup task, then
494     /// the worker will delete some cache files, so after the task, the file count should not
495     /// exceed file-count-soft-limit * file-count-limit-percent-if-deleting.
496     ///
497     /// This doesn't include files with metadata. To learn more, please refer to the cache system
498     /// section.
499     pub fn with_file_count_limit_percent_if_deleting(&mut self, percent: u8) -> &mut Self {
500         self.file_count_limit_percent_if_deleting = percent;
501         self
502     }
503 
504     /// If files-total-size-soft-limit is exceeded and cache worker performs the cleanup task, then
505     /// the worker will delete some cache files, so after the task, the files total size should not
506     /// exceed files-total-size-soft-limit * files-total-size-limit-percent-if-deleting.
507     ///
508     /// This doesn't include files with metadata. To learn more, please refer to the cache system
509     /// section.
510     pub fn with_files_total_size_limit_percent_if_deleting(&mut self, percent: u8) -> &mut Self {
511         self.files_total_size_limit_percent_if_deleting = percent;
512         self
513     }
514 
515     /// validate values and fill in defaults
516     pub(crate) fn validate(&mut self) -> Result<()> {
517         self.validate_directory_or_default()?;
518         self.validate_worker_event_queue_size();
519         self.validate_baseline_compression_level()?;
520         self.validate_optimized_compression_level()?;
521         self.validate_file_count_limit_percent_if_deleting()?;
522         self.validate_files_total_size_limit_percent_if_deleting()?;
523         Ok(())
524     }
525 
526     fn validate_directory_or_default(&mut self) -> Result<()> {
527         if self.directory.is_none() {
528             match project_dirs() {
529                 Some(proj_dirs) => self.directory = Some(proj_dirs.cache_dir().to_path_buf()),
530                 None => {
531                     bail!("Cache directory not specified and failed to get the default");
532                 }
533             }
534         }
535 
536         // On Windows, if we want long paths, we need '\\?\' prefix, but it doesn't work
537         // with relative paths. One way to get absolute path (the only one?) is to use
538         // fs::canonicalize, but it requires that given path exists. The extra advantage
539         // of this method is fact that the method prepends '\\?\' on Windows.
540         let cache_dir = self.directory.as_ref().unwrap();
541 
542         if !cache_dir.is_absolute() {
543             bail!(
544                 "Cache directory path has to be absolute, path: {}",
545                 cache_dir.display(),
546             );
547         }
548 
549         fs::create_dir_all(cache_dir).context(format!(
550             "failed to create cache directory: {}",
551             cache_dir.display()
552         ))?;
553         let canonical = fs::canonicalize(cache_dir).context(format!(
554             "failed to canonicalize cache directory: {}",
555             cache_dir.display()
556         ))?;
557         self.directory = Some(canonical);
558         Ok(())
559     }
560 
561     fn validate_worker_event_queue_size(&self) {
562         if self.worker_event_queue_size < worker_event_queue_size_warning_threshold() {
563             warn!("Detected small worker event queue size. Some messages might be lost.");
564         }
565     }
566 
567     fn validate_baseline_compression_level(&self) -> Result<()> {
568         if !ZSTD_COMPRESSION_LEVELS.contains(&self.baseline_compression_level) {
569             bail!(
570                 "Invalid baseline compression level: {} not in {:#?}",
571                 self.baseline_compression_level,
572                 ZSTD_COMPRESSION_LEVELS
573             );
574         }
575         Ok(())
576     }
577 
578     // assumption: baseline compression level has been verified
579     fn validate_optimized_compression_level(&self) -> Result<()> {
580         if !ZSTD_COMPRESSION_LEVELS.contains(&self.optimized_compression_level) {
581             bail!(
582                 "Invalid optimized compression level: {} not in {:#?}",
583                 self.optimized_compression_level,
584                 ZSTD_COMPRESSION_LEVELS
585             );
586         }
587 
588         if self.optimized_compression_level < self.baseline_compression_level {
589             bail!(
590                 "Invalid optimized compression level is lower than baseline: {} < {}",
591                 self.optimized_compression_level,
592                 self.baseline_compression_level
593             );
594         }
595         Ok(())
596     }
597 
598     fn validate_file_count_limit_percent_if_deleting(&self) -> Result<()> {
599         if self.file_count_limit_percent_if_deleting > 100 {
600             bail!(
601                 "Invalid files count limit percent if deleting: {} not in range 0-100%",
602                 self.file_count_limit_percent_if_deleting
603             );
604         }
605         Ok(())
606     }
607 
608     fn validate_files_total_size_limit_percent_if_deleting(&self) -> Result<()> {
609         if self.files_total_size_limit_percent_if_deleting > 100 {
610             bail!(
611                 "Invalid files total size limit percent if deleting: {} not in range 0-100%",
612                 self.files_total_size_limit_percent_if_deleting
613             );
614         }
615         Ok(())
616     }
617 }
618 
619 #[cfg(test)]
620 #[macro_use]
621 pub mod tests;
622