1 //! Module for configuring the cache system. 2 3 use directories_next::ProjectDirs; 4 use log::{trace, warn}; 5 use serde::{ 6 Deserialize, 7 de::{self, Deserializer}, 8 }; 9 use std::fmt::Debug; 10 use std::fs; 11 use std::path::{Path, PathBuf}; 12 use std::time::Duration; 13 use wasmtime_environ::error::{Context, Result, anyhow, bail}; 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 macro_rules! generate_setting_getter { 309 ($setting:ident: $setting_type:ty) => { 310 #[doc = concat!("Returns ", "`", stringify!($setting), "`.")] 311 pub fn $setting(&self) -> $setting_type { 312 self.$setting 313 } 314 }; 315 } 316 317 impl CacheConfig { 318 /// Creates a cache configuration with default settings. 319 pub fn new() -> Self { 320 Self::default() 321 } 322 323 /// Loads cache configuration specified at `path`. 324 /// 325 /// This method will read the file specified by `path` on the filesystem and 326 /// attempt to load cache configuration from it. This method can also fail 327 /// due to I/O errors, misconfiguration, syntax errors, etc. For expected 328 /// syntax in the configuration file see the [documentation online][docs]. 329 /// 330 /// Passing in `None` loads cache configuration from the system default path. 331 /// This is located, for example, on Unix at `$HOME/.config/wasmtime/config.toml` 332 /// and is typically created with the `wasmtime config new` command. 333 /// 334 /// # Errors 335 /// 336 /// This method can fail due to any error that happens when loading the file 337 /// pointed to by `path` and attempting to load the cache configuration. 338 /// 339 /// [docs]: https://bytecodealliance.github.io/wasmtime/cli-cache.html 340 pub fn from_file(config_file: Option<&Path>) -> Result<Self> { 341 let mut config = Self::load_and_parse_file(config_file)?; 342 config.validate()?; 343 Ok(config) 344 } 345 346 fn load_and_parse_file(config_file: Option<&Path>) -> Result<Self> { 347 // get config file path 348 let (config_file, user_custom_file) = match config_file { 349 Some(path) => (path.to_path_buf(), true), 350 None => (default_config_path()?, false), 351 }; 352 353 // read config, or use default one 354 let entity_exists = config_file.exists(); 355 match (entity_exists, user_custom_file) { 356 (false, false) => Ok(Self::new()), 357 _ => { 358 let contents = fs::read_to_string(&config_file).context(format!( 359 "failed to read config file: {}", 360 config_file.display() 361 ))?; 362 let config = toml::from_str::<Config>(&contents).context(format!( 363 "failed to parse config file: {}", 364 config_file.display() 365 ))?; 366 Ok(config.cache) 367 } 368 } 369 } 370 371 generate_setting_getter!(worker_event_queue_size: u64); 372 generate_setting_getter!(baseline_compression_level: i32); 373 generate_setting_getter!(optimized_compression_level: i32); 374 generate_setting_getter!(optimized_compression_usage_counter_threshold: u64); 375 generate_setting_getter!(cleanup_interval: Duration); 376 generate_setting_getter!(optimizing_compression_task_timeout: Duration); 377 generate_setting_getter!(allowed_clock_drift_for_files_from_future: Duration); 378 generate_setting_getter!(file_count_soft_limit: u64); 379 generate_setting_getter!(files_total_size_soft_limit: u64); 380 generate_setting_getter!(file_count_limit_percent_if_deleting: u8); 381 generate_setting_getter!(files_total_size_limit_percent_if_deleting: u8); 382 383 /// Returns path to the cache directory if one is set. 384 pub fn directory(&self) -> Option<&PathBuf> { 385 self.directory.as_ref() 386 } 387 388 /// Specify where the cache directory is. Must be an absolute path. 389 pub fn with_directory(&mut self, directory: impl Into<PathBuf>) -> &mut Self { 390 self.directory = Some(directory.into()); 391 self 392 } 393 394 /// Size of cache worker event queue. If the queue is full, incoming cache usage events will be 395 /// dropped. 396 pub fn with_worker_event_queue_size(&mut self, size: u64) -> &mut Self { 397 self.worker_event_queue_size = size; 398 self 399 } 400 401 /// Compression level used when a new cache file is being written by the cache system. Wasmtime 402 /// uses zstd compression. 403 pub fn with_baseline_compression_level(&mut self, level: i32) -> &mut Self { 404 self.baseline_compression_level = level; 405 self 406 } 407 408 /// Compression level used when the cache worker decides to recompress a cache file. Wasmtime 409 /// uses zstd compression. 410 pub fn with_optimized_compression_level(&mut self, level: i32) -> &mut Self { 411 self.optimized_compression_level = level; 412 self 413 } 414 415 /// One of the conditions for the cache worker to recompress a cache file is to have usage 416 /// count of the file exceeding this threshold. 417 pub fn with_optimized_compression_usage_counter_threshold( 418 &mut self, 419 threshold: u64, 420 ) -> &mut Self { 421 self.optimized_compression_usage_counter_threshold = threshold; 422 self 423 } 424 425 /// When the cache worker is notified about a cache file being updated by the cache system and 426 /// this interval has already passed since last cleaning up, the worker will attempt a new 427 /// cleanup. 428 pub fn with_cleanup_interval(&mut self, interval: Duration) -> &mut Self { 429 self.cleanup_interval = interval; 430 self 431 } 432 433 /// When the cache worker decides to recompress a cache file, it makes sure that no other 434 /// worker has started the task for this file within the last 435 /// optimizing-compression-task-timeout interval. If some worker has started working on it, 436 /// other workers are skipping this task. 437 pub fn with_optimizing_compression_task_timeout(&mut self, timeout: Duration) -> &mut Self { 438 self.optimizing_compression_task_timeout = timeout; 439 self 440 } 441 442 /// ### Locks 443 /// 444 /// When the cache worker attempts acquiring a lock for some task, it checks if some other 445 /// worker has already acquired such a lock. To be fault tolerant and eventually execute every 446 /// task, the locks expire after some interval. However, because of clock drifts and different 447 /// timezones, it would happen that some lock was created in the future. This setting defines a 448 /// tolerance limit for these locks. If the time has been changed in the system (i.e. two years 449 /// backwards), the cache system should still work properly. Thus, these locks will be treated 450 /// as expired (assuming the tolerance is not too big). 451 /// 452 /// ### Cache files 453 /// 454 /// Similarly to the locks, the cache files or their metadata might have modification time in 455 /// distant future. The cache system tries to keep these files as long as possible. If the 456 /// limits are not reached, the cache files will not be deleted. Otherwise, they will be 457 /// treated as the oldest files, so they might survive. If the user actually uses the cache 458 /// file, the modification time will be updated. 459 pub fn with_allowed_clock_drift_for_files_from_future(&mut self, drift: Duration) -> &mut Self { 460 self.allowed_clock_drift_for_files_from_future = drift; 461 self 462 } 463 464 /// Soft limit for the file count in the cache directory. 465 /// 466 /// This doesn't include files with metadata. To learn more, please refer to the cache system 467 /// section. 468 pub fn with_file_count_soft_limit(&mut self, limit: u64) -> &mut Self { 469 self.file_count_soft_limit = limit; 470 self 471 } 472 473 /// Soft limit for the total size* of files 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 /// 478 /// *this is the file size, not the space physically occupied on the disk. 479 pub fn with_files_total_size_soft_limit(&mut self, limit: u64) -> &mut Self { 480 self.files_total_size_soft_limit = limit; 481 self 482 } 483 484 /// If file-count-soft-limit is exceeded and the cache worker performs the cleanup task, then 485 /// the worker will delete some cache files, so after the task, the file count should not 486 /// exceed file-count-soft-limit * file-count-limit-percent-if-deleting. 487 /// 488 /// This doesn't include files with metadata. To learn more, please refer to the cache system 489 /// section. 490 pub fn with_file_count_limit_percent_if_deleting(&mut self, percent: u8) -> &mut Self { 491 self.file_count_limit_percent_if_deleting = percent; 492 self 493 } 494 495 /// If files-total-size-soft-limit is exceeded and cache worker performs the cleanup task, then 496 /// the worker will delete some cache files, so after the task, the files total size should not 497 /// exceed files-total-size-soft-limit * files-total-size-limit-percent-if-deleting. 498 /// 499 /// This doesn't include files with metadata. To learn more, please refer to the cache system 500 /// section. 501 pub fn with_files_total_size_limit_percent_if_deleting(&mut self, percent: u8) -> &mut Self { 502 self.files_total_size_limit_percent_if_deleting = percent; 503 self 504 } 505 506 /// validate values and fill in defaults 507 pub(crate) fn validate(&mut self) -> Result<()> { 508 self.validate_directory_or_default()?; 509 self.validate_worker_event_queue_size(); 510 self.validate_baseline_compression_level()?; 511 self.validate_optimized_compression_level()?; 512 self.validate_file_count_limit_percent_if_deleting()?; 513 self.validate_files_total_size_limit_percent_if_deleting()?; 514 Ok(()) 515 } 516 517 fn validate_directory_or_default(&mut self) -> Result<()> { 518 if self.directory.is_none() { 519 match project_dirs() { 520 Some(proj_dirs) => self.directory = Some(proj_dirs.cache_dir().to_path_buf()), 521 None => { 522 bail!("Cache directory not specified and failed to get the default"); 523 } 524 } 525 } 526 527 // On Windows, if we want long paths, we need '\\?\' prefix, but it doesn't work 528 // with relative paths. One way to get absolute path (the only one?) is to use 529 // fs::canonicalize, but it requires that given path exists. The extra advantage 530 // of this method is fact that the method prepends '\\?\' on Windows. 531 let cache_dir = self.directory.as_ref().unwrap(); 532 533 if !cache_dir.is_absolute() { 534 bail!( 535 "Cache directory path has to be absolute, path: {}", 536 cache_dir.display(), 537 ); 538 } 539 540 fs::create_dir_all(cache_dir).context(format!( 541 "failed to create cache directory: {}", 542 cache_dir.display() 543 ))?; 544 let canonical = fs::canonicalize(cache_dir).context(format!( 545 "failed to canonicalize cache directory: {}", 546 cache_dir.display() 547 ))?; 548 self.directory = Some(canonical); 549 Ok(()) 550 } 551 552 fn validate_worker_event_queue_size(&self) { 553 if self.worker_event_queue_size < worker_event_queue_size_warning_threshold() { 554 warn!("Detected small worker event queue size. Some messages might be lost."); 555 } 556 } 557 558 fn validate_baseline_compression_level(&self) -> Result<()> { 559 if !ZSTD_COMPRESSION_LEVELS.contains(&self.baseline_compression_level) { 560 bail!( 561 "Invalid baseline compression level: {} not in {:#?}", 562 self.baseline_compression_level, 563 ZSTD_COMPRESSION_LEVELS 564 ); 565 } 566 Ok(()) 567 } 568 569 // assumption: baseline compression level has been verified 570 fn validate_optimized_compression_level(&self) -> Result<()> { 571 if !ZSTD_COMPRESSION_LEVELS.contains(&self.optimized_compression_level) { 572 bail!( 573 "Invalid optimized compression level: {} not in {:#?}", 574 self.optimized_compression_level, 575 ZSTD_COMPRESSION_LEVELS 576 ); 577 } 578 579 if self.optimized_compression_level < self.baseline_compression_level { 580 bail!( 581 "Invalid optimized compression level is lower than baseline: {} < {}", 582 self.optimized_compression_level, 583 self.baseline_compression_level 584 ); 585 } 586 Ok(()) 587 } 588 589 fn validate_file_count_limit_percent_if_deleting(&self) -> Result<()> { 590 if self.file_count_limit_percent_if_deleting > 100 { 591 bail!( 592 "Invalid files count limit percent if deleting: {} not in range 0-100%", 593 self.file_count_limit_percent_if_deleting 594 ); 595 } 596 Ok(()) 597 } 598 599 fn validate_files_total_size_limit_percent_if_deleting(&self) -> Result<()> { 600 if self.files_total_size_limit_percent_if_deleting > 100 { 601 bail!( 602 "Invalid files total size limit percent if deleting: {} not in range 0-100%", 603 self.files_total_size_limit_percent_if_deleting 604 ); 605 } 606 Ok(()) 607 } 608 } 609 610 #[cfg(test)] 611 #[macro_use] 612 pub mod tests; 613