wasmtime_internal_cache/
config.rs

1//! Module for configuring the cache system.
2
3use anyhow::{Context, Result, anyhow, bail};
4use directories_next::ProjectDirs;
5use log::{trace, warn};
6use serde::{
7    Deserialize,
8    de::{self, Deserializer},
9};
10use std::fmt::Debug;
11use std::fs;
12use std::path::{Path, PathBuf};
13use 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)]
19struct 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)]
26pub 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
94impl 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.
118pub 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
163const 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
171const fn default_worker_event_queue_size() -> u64 {
172    0x10
173}
174const 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
179const 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
184const 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
189const fn default_optimized_compression_usage_counter_threshold() -> u64 {
190    0x100
191}
192// if changed, update cli-cache.md
193const fn default_cleanup_interval() -> Duration {
194    Duration::from_secs(60 * 60)
195}
196// if changed, update cli-cache.md
197const 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
203const 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
207const fn default_file_count_soft_limit() -> u64 {
208    0x10_000
209}
210// if changed, update cli-cache.md
211const fn default_files_total_size_soft_limit() -> u64 {
212    1024 * 1024 * 512
213}
214// if changed, update cli-cache.md
215const fn default_file_count_limit_percent_if_deleting() -> u8 {
216    70
217}
218// if changed, update cli-cache.md
219const fn default_files_total_size_limit_percent_if_deleting() -> u8 {
220    70
221}
222
223fn project_dirs() -> Option<ProjectDirs> {
224    ProjectDirs::from("", "BytecodeAlliance", "wasmtime")
225}
226
227fn 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
236macro_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
262generate_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
272generate_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
284generate_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
301generate_deserializer!(deserialize_percent(num: u8, unit: &str) -> u8 {
302    match unit {
303        "%" => Some(num),
304        _ => None,
305    }
306});
307
308static CACHE_IMPROPER_CONFIG_ERROR_MSG: &str =
309    "Cache system should be enabled and all settings must be validated or defaulted";
310
311macro_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
322impl 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]
621pub mod tests;