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