From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 3BBA2F094 for ; Thu, 28 Sep 2023 13:50:52 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id C717A15AA4 for ; Thu, 28 Sep 2023 13:50:21 +0200 (CEST) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS for ; Thu, 28 Sep 2023 13:50:18 +0200 (CEST) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 5EEC248E42 for ; Thu, 28 Sep 2023 13:50:18 +0200 (CEST) From: Lukas Wagner To: pve-devel@lists.proxmox.com Date: Thu, 28 Sep 2023 13:50:10 +0200 Message-Id: <20230928115012.326777-6-l.wagner@proxmox.com> X-Mailer: git-send-email 2.39.2 In-Reply-To: <20230928115012.326777-1-l.wagner@proxmox.com> References: <20230928115012.326777-1-l.wagner@proxmox.com> MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.031 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pve-devel] [PATCH v2 proxmox 5/7] cache: add new crate 'proxmox-shared-cache' X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Thu, 28 Sep 2023 11:50:52 -0000 This crate contains a file-backed cache with expiration logic. The cache should be safe to be accessed from multiple processes at once. The cache stores values in a directory, based on the key. E.g. key "foo" results in a file 'foo.json' in the given base directory. If a new value is set, the file is atomically replaced. The JSON file also contains some metadata, namely 'added_at' and 'expire_in' - they are used for cache expiration. Note: This cache is not suited to applications that - Might want to cache huge amounts of data, and/or access the cache very frequently (due to the overhead of JSON de/serialization) - Require arbitrary keys - right now, keys are limited by SAFE_ID_REGEX The cache was developed for the use in pvestatd, in order to cache e.g. storage plugin status. There, these limitations do not really play any role. Signed-off-by: Lukas Wagner --- Cargo.toml | 1 + proxmox-shared-cache/Cargo.toml | 18 + proxmox-shared-cache/debian/changelog | 5 + proxmox-shared-cache/debian/control | 53 ++ proxmox-shared-cache/debian/copyright | 18 + proxmox-shared-cache/debian/debcargo.toml | 7 + proxmox-shared-cache/examples/performance.rs | 113 +++++ proxmox-shared-cache/src/lib.rs | 485 +++++++++++++++++++ 8 files changed, 700 insertions(+) create mode 100644 proxmox-shared-cache/Cargo.toml create mode 100644 proxmox-shared-cache/debian/changelog create mode 100644 proxmox-shared-cache/debian/control create mode 100644 proxmox-shared-cache/debian/copyright create mode 100644 proxmox-shared-cache/debian/debcargo.toml create mode 100644 proxmox-shared-cache/examples/performance.rs create mode 100644 proxmox-shared-cache/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index e334ac1..7b1e8e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "proxmox-schema", "proxmox-section-config", "proxmox-serde", + "proxmox-shared-cache", "proxmox-shared-memory", "proxmox-sortable-macro", "proxmox-subscription", diff --git a/proxmox-shared-cache/Cargo.toml b/proxmox-shared-cache/Cargo.toml new file mode 100644 index 0000000..ada1a12 --- /dev/null +++ b/proxmox-shared-cache/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "proxmox-shared-cache" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +exclude.workspace = true +description = "A cache that can be used from multiple processes simultaneously" + +[dependencies] +anyhow.workspace = true +proxmox-sys = { workspace = true, features = ["timer"] } +proxmox-time.workspace = true +proxmox-schema = { workspace = true, features = ["api-types"]} +serde_json = { workspace = true, features = ["raw_value"] } +serde = { workspace = true, features = ["derive"]} +nix.workspace = true diff --git a/proxmox-shared-cache/debian/changelog b/proxmox-shared-cache/debian/changelog new file mode 100644 index 0000000..54d39f5 --- /dev/null +++ b/proxmox-shared-cache/debian/changelog @@ -0,0 +1,5 @@ +rust-proxmox-shared-cache (0.1.0-1) unstable; urgency=medium + + * initial Debian package + + -- Proxmox Support Team Thu, 04 May 2023 08:40:38 +0200 diff --git a/proxmox-shared-cache/debian/control b/proxmox-shared-cache/debian/control new file mode 100644 index 0000000..c8f0d8e --- /dev/null +++ b/proxmox-shared-cache/debian/control @@ -0,0 +1,53 @@ +Source: rust-proxmox-shared-cache +Section: rust +Priority: optional +Build-Depends: debhelper (>= 12), + dh-cargo (>= 25), + cargo:native , + rustc:native , + libstd-rust-dev , + librust-anyhow-1+default-dev , + librust-nix-0.26+default-dev (>= 0.26.1-~~) , + librust-proxmox-schema-2+api-types-dev , + librust-proxmox-schema-2+default-dev , + librust-proxmox-sys-0.5+default-dev , + librust-proxmox-sys-0.5+timer-dev , + librust-proxmox-time-1+default-dev (>= 1.1.4-~~) , + librust-serde-1+default-dev , + librust-serde-1+derive-dev , + librust-serde-json-1+default-dev , + librust-serde-json-1+raw-value-dev +Maintainer: Proxmox Support Team +Standards-Version: 4.6.1 +Vcs-Git: https://salsa.debian.org/rust-team/debcargo-conf.git [src/proxmox-shared-cache] +Vcs-Browser: https://salsa.debian.org/rust-team/debcargo-conf/tree/master/src/proxmox-shared-cache +X-Cargo-Crate: proxmox-shared-cache +Rules-Requires-Root: no + +Package: librust-proxmox-shared-cache-dev +Architecture: any +Multi-Arch: same +Depends: + ${misc:Depends}, + librust-anyhow-1+default-dev, + librust-nix-0.26+default-dev (>= 0.26.1-~~), + librust-proxmox-schema-2+api-types-dev, + librust-proxmox-schema-2+default-dev, + librust-proxmox-sys-0.5+default-dev, + librust-proxmox-sys-0.5+timer-dev, + librust-proxmox-time-1+default-dev (>= 1.1.4-~~), + librust-serde-1+default-dev, + librust-serde-1+derive-dev, + librust-serde-json-1+default-dev, + librust-serde-json-1+raw-value-dev +Provides: + librust-proxmox-shared-cache+default-dev (= ${binary:Version}), + librust-proxmox-shared-cache-0-dev (= ${binary:Version}), + librust-proxmox-shared-cache-0+default-dev (= ${binary:Version}), + librust-proxmox-shared-cache-0.1-dev (= ${binary:Version}), + librust-proxmox-shared-cache-0.1+default-dev (= ${binary:Version}), + librust-proxmox-shared-cache-0.1.0-dev (= ${binary:Version}), + librust-proxmox-shared-cache-0.1.0+default-dev (= ${binary:Version}) +Description: Cache implementations - Rust source code + This package contains the source for the Rust proxmox-shared-cache crate, + packaged by debcargo for use with cargo and dh-cargo. diff --git a/proxmox-shared-cache/debian/copyright b/proxmox-shared-cache/debian/copyright new file mode 100644 index 0000000..0d9eab3 --- /dev/null +++ b/proxmox-shared-cache/debian/copyright @@ -0,0 +1,18 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + +Files: + * +Copyright: 2019 - 2023 Proxmox Server Solutions GmbH +License: AGPL-3.0-or-later + This program is free software: you can redistribute it and/or modify it under + the terms of the GNU Affero General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) any + later version. + . + This program is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + details. + . + You should have received a copy of the GNU Affero General Public License along + with this program. If not, see . diff --git a/proxmox-shared-cache/debian/debcargo.toml b/proxmox-shared-cache/debian/debcargo.toml new file mode 100644 index 0000000..14ad800 --- /dev/null +++ b/proxmox-shared-cache/debian/debcargo.toml @@ -0,0 +1,7 @@ +overlay = "." +crate_src_path = ".." +maintainer = "Proxmox Support Team " + +[source] +#vcs_git = "git://git.proxmox.com/git/proxmox.git" +#vcs_browser = "https://git.proxmox.com/?p=proxmox.git" diff --git a/proxmox-shared-cache/examples/performance.rs b/proxmox-shared-cache/examples/performance.rs new file mode 100644 index 0000000..54a9bf9 --- /dev/null +++ b/proxmox-shared-cache/examples/performance.rs @@ -0,0 +1,113 @@ +use proxmox_shared_cache::SharedCache; +use proxmox_sys::fs::CreateOptions; +use serde_json::Value; +use std::time::{Duration, Instant}; + +fn main() { + let options = CreateOptions::new() + .owner(nix::unistd::Uid::effective()) + .group(nix::unistd::Gid::effective()) + .perm(nix::sys::stat::Mode::from_bits_truncate(0o755)); + + let cache = SharedCache::new("/tmp/pmx-cache", options).unwrap(); + + let mut keys = Vec::new(); + + for i in 0..100000 { + keys.push(format!("key_{i}")); + } + + let data = serde_json::json!({ + "member1": "foo", + "member2": "foo", + "member3": "foo", + "member4": "foo", + "member5": "foo", + "member5": "foo", + "member6": "foo", + "member7": "foo", + "member8": "foo", + "array": [10, 20, 30, 40, 50], + "object": { + "member1": "foo", + "member2": "foo", + "member3": "foo", + "member4": "foo", + "member5": "foo", + "member5": "foo", + "member6": "foo", + "member7": "foo", + "member8": "foo", + } + }); + + // ##################################### + let before = Instant::now(); + + for key in &keys { + cache.set(key, &data, None).expect("could not insert value"); + } + + let time = Instant::now() - before; + let time_per_op = time / keys.len() as u32; + println!( + "inserting {len} keys took {time:?} ({time_per_op:?} per key)", + len = keys.len(), + ); + + // ##################################### + let before = Instant::now(); + for key in &keys { + let _: Option = cache.get(key).expect("could not get value"); + } + + let time = Instant::now() - before; + let time_per_op = time / keys.len() as u32; + println!( + "getting {len} unexpired keys took {time:?} ({time_per_op:?} per key)", + len = keys.len(), + ); + + // ##################################### + let before = Instant::now(); + for key in &keys { + cache + .set(key, &data, Some(0)) + .expect("could not insert value"); + } + + let time = Instant::now() - before; + let time_per_op = time / keys.len() as u32; + println!( + "updating {len} keys took {time:?} ({time_per_op:?} per key)", + len = keys.len(), + ); + + std::thread::sleep(Duration::from_secs(1)); + + // ##################################### + let before = Instant::now(); + for key in &keys { + let _: Option = cache.get(key).expect("could not get value"); + } + + let time = Instant::now() - before; + let time_per_op = time / keys.len() as u32; + println!( + "getting {len} expired keys took {time:?} ({time_per_op:?} per key)", + len = keys.len(), + ); + + // ##################################### + let before = Instant::now(); + for key in &keys { + cache.delete(key).expect("could not delete value"); + } + + let time = Instant::now() - before; + let time_per_op = time / keys.len() as u32; + println!( + "deleting {len} keys took {time:?} ({time_per_op:?} per key)", + len = keys.len(), + ); +} diff --git a/proxmox-shared-cache/src/lib.rs b/proxmox-shared-cache/src/lib.rs new file mode 100644 index 0000000..c63de91 --- /dev/null +++ b/proxmox-shared-cache/src/lib.rs @@ -0,0 +1,485 @@ +use std::fs::File; +use std::os::fd::{FromRawFd, IntoRawFd, RawFd}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{bail, Error}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; + +use proxmox_schema::api_types::SAFE_ID_FORMAT; +use proxmox_sys::fs::CreateOptions; + +/// Lock guard for a locked cache entry. +/// +/// The lock is dropped when the guard is dropped. +pub struct CacheLockGuard(File); + +impl FromRawFd for CacheLockGuard { + unsafe fn from_raw_fd(fd: RawFd) -> Self { + CacheLockGuard(File::from_raw_fd(fd)) + } +} + +impl IntoRawFd for CacheLockGuard { + fn into_raw_fd(self) -> RawFd { + self.0.into_raw_fd() + } +} + +/// A simple, file-backed cache that can be used from multiple processes concurrently. +/// +/// Cache entries are stored as individual files inside a base directory. For instance, +/// a cache entry with the key 'disk_stats' will result in a file 'disk_stats.json' inside +/// the base directory. As the extension implies, the cached data will be stored as a JSON +/// string. +/// +/// For optimal performance, `SharedCache` should have its base directory in a `tmpfs`. +/// +/// ## Key Space +/// Due to the fact that cache keys are being directly used as filenames, they have to match the +/// following regular expression: `[A-Za-z0-9_][A-Za-z0-9._\-]*` +/// +/// ## Concurrency +/// set/delete will use file locking to ensure that there are no race conditions. +/// get does not require any locking, since all file operations used by set/delete should be +/// atomic. +/// +/// If multiple cache operations must be at the same locked context, `lock` can be used +/// to manually lock a cache entry. The returned lock guard can then be passed to +/// `{set,delete,get}_with_lock`. +/// If multiple keys are locked at the same time, make sure that you always lock them +/// in same order (e.g. by sorting the keys) - otherwise circular waits can occur. +/// +/// ## Performance +/// On a tmpfs: +/// ```sh +/// $ cargo run --release --example=performance +/// inserting 100000 keys took 2.495008362s (24.95µs per key) +/// getting 100000 unexpired keys took 1.557399535s (15.573µs per key) +/// updating 100000 keys took 2.488894178s (24.888µs per key) +/// getting 100000 expired keys took 1.324983239s (13.249µs per key) +/// deleting 100000 keys took 1.533744028s (15.337µs per key) +/// +/// Inserting/getting large objects might of course result in lower performance due to the cost +/// of serialization. +/// ``` +/// +/// # Limitations +/// - At the moment, stale/expired keys are never cleaned - at the moment +/// of creation this was simply not needed, since we only use the crate for +/// caching data in pvestatd with a limited set of keys that are not changing +/// - so there will not be any cache entries that need to be cleaned up. +/// +pub struct SharedCache { + base_path: PathBuf, + create_options: CreateOptions, + #[cfg(test)] + time: std::cell::Cell, +} + +impl SharedCache { + /// Instantiate a new cache instance for a given `base_path`. + /// + /// If `base_path` does not exist, it will be created - the access permissions + /// are determined by `options`. + /// If the base directory already contains cache entries, they will be available + /// via `get` for later retrieval. In other words, `SharedCache::new` never touches + /// existing cache entries. + pub fn new>(base_path: P, options: CreateOptions) -> Result { + proxmox_sys::fs::create_path( + base_path.as_ref(), + Some(options.clone()), + Some(options.clone()), + )?; + + Ok(SharedCache { + base_path: base_path.as_ref().to_owned(), + create_options: options, + #[cfg(test)] + time: std::cell::Cell::new(0), + }) + } + + /// Set a cache entry, with optional value expiration. + /// + /// This method will attempt to lock the cache entry before deleting it. + /// + /// Keys have to match the following regular expression to be valid: + /// `[A-Za-z0-9_][A-Za-z0-9._\-]*` + /// + /// Returns an error if value serialization or storing the value failed. + pub fn set, V: Serialize>( + &self, + key: S, + value: &V, + expires_in: Option, + ) -> Result<(), Error> { + let lock = self.lock(key.as_ref(), true)?; + self.set_with_lock(key, value, expires_in, &lock) + } + + /// Set a cache entry, with optional value expiration. + /// + /// This method assumes that the cache entry was locked before using `lock`. + /// + /// Keys have to match the following regular expression to be valid: + /// `[A-Za-z0-9_][A-Za-z0-9._\-]*` + /// + /// Returns an error if value serialization or storing the value failed. + pub fn set_with_lock, V: Serialize>( + &self, + key: S, + value: &V, + expires_in: Option, + _lock: &CacheLockGuard, + ) -> Result<(), Error> { + let path = self.get_entry_path(key.as_ref())?; + let added_at = self.get_time(); + + let item = CachedItem { + value, + added_at, + expires_in, + }; + + let serialized = serde_json::to_vec_pretty(&item)?; + + // Atomically replace file + proxmox_sys::fs::replace_file(path, &serialized, self.create_options.clone(), true)?; + Ok(()) + } + + /// Delete a cache entry. + /// + /// This method will attempt to lock the cache entry before deleting it. + /// + /// Keys have to match the following regular expression to be valid: + /// `[A-Za-z0-9_][A-Za-z0-9._\-]*` + /// + /// Returns an error if the entry could not be deleted. + pub fn delete>(&self, key: S) -> Result<(), Error> { + let lock = self.lock(key.as_ref(), true)?; + self.delete_with_lock(key.as_ref(), &lock)?; + + Ok(()) + } + + /// Delete a cache entry. + /// + /// This method assumes that the cache entry was locked before using `lock`. + /// + /// Keys have to match the following regular expression to be valid: + /// `[A-Za-z0-9_][A-Za-z0-9._\-]*` + /// + /// Returns an error if the entry could not be deleted. + pub fn delete_with_lock>( + &self, + key: S, + _lock: &CacheLockGuard, + ) -> Result<(), Error> { + let path = self.get_entry_path(key.as_ref())?; + std::fs::remove_file(path)?; + + // Unlink the lock file's dir entry from the fs, but since we have + // an open file handle for the lock file, it continues to exist until + // the handle is closed + std::fs::remove_file(self.get_lockfile_path(key.as_ref())?)?; + + Ok(()) + } + + /// Get a value from the cache. + /// + /// This method will attempt to lock the entry with a non-exclusive lock before reading it. + /// + /// Keys have to match the following regular expression to be valid: + /// `[A-Za-z0-9_][A-Za-z0-9._\-]*` + /// + /// Returns an error if the entry could not be retrieved. + pub fn get, V: DeserializeOwned>(&self, key: S) -> Result, Error> { + let lock = self.lock(key.as_ref(), false)?; + self.get_with_lock(key, &lock) + } + + /// Get a value from the cache. + /// + /// If the key does not exist or the value has expired, `Ok(None)` is returned. + /// This method assumes that the cache entry was locked before using `lock`. + /// + /// Keys have to match the following regular expression to be valid: + /// `[A-Za-z0-9_][A-Za-z0-9._\-]*` + /// + /// Returns an error if the entry could not be retrieved. + pub fn get_with_lock, V: DeserializeOwned>( + &self, + key: S, + _lock: &CacheLockGuard, + ) -> Result, Error> { + let path = self.get_entry_path(key.as_ref())?; + + if let Some(content) = proxmox_sys::fs::file_get_optional_contents(path)? { + // Use RawValue so that we can deserialize the actual payload after + // checking value expiry. This should improve performance for large payloads. + let value: CachedItem<&'_ RawValue> = serde_json::from_slice(&content)?; + + let now = self.get_time(); + + if let Some(expires_in) = value.expires_in { + // Check if value is not expired yet. Also do not allow + // values from the future, in case we have clock jumps + if value.added_at + expires_in > now && value.added_at <= now { + Ok(Some(serde_json::from_str(value.value.get())?)) + } else { + Ok(None) + } + } else { + Ok(Some(serde_json::from_str(value.value.get())?)) + } + } else { + Ok(None) + } + } + + /// Get value from the cache. If it does not exist/is expired, compute + /// the new value from a passed closure and insert it into the cache. + /// + /// Keys have to match the following regular expression to be valid: + /// `[A-Za-z0-9_][A-Za-z0-9._\-]*` + pub fn get_or_update( + &self, + key: K, + value_func: &mut F, + expires_in: Option, + ) -> Result + where + K: AsRef, + F: FnMut() -> Result, + V: Serialize + DeserializeOwned, + { + // Lookup value and return if it exists and has not expired yet + // get uses a non-exclusive lock, so we can have concurrent lookups + let val = self.get(key.as_ref())?; + if let Some(val) = val { + return Ok(val); + } + + // If not, lock the entry ... + let lock = self.lock(key.as_ref(), true)?; + + // ... and check again, maybe somebody else has set the value + // before we locked it. + let val = self.get_with_lock(key.as_ref(), &lock)?; + if let Some(val) = val { + return Ok(val); + } + + // If the value is still not there, compute its new value and store it + let val = value_func()?; + self.set_with_lock(key.as_ref(), &val, expires_in, &lock)?; + + Ok(val) + } + + /// Locks a cache entry. + /// + /// Useful if you must perform multiple operations while being locked. + /// + /// This will create a lockfile `/.lck` which will be locked + /// with an advisory file lock via `flock`. + /// + /// On success, `CacheLockGuard` is returned. It serves as a handle to + /// the open lock file. If the handle is dropped, the lock is removed. + /// + /// Keys have to match the following regular expression to be valid: + /// `[A-Za-z0-9_][A-Za-z0-9._\-]*` + /// + /// Returns an error if the entry could not be locked. + pub fn lock>(&self, key: S, exclusive: bool) -> Result { + let mut path = self.get_entry_path(key.as_ref())?; + path.set_extension("lck"); + + let options = proxmox_sys::fs::CreateOptions::new() + .perm(nix::sys::stat::Mode::from_bits_truncate(0o660)); + + let lockfile = + proxmox_sys::fs::open_file_locked(path, Duration::from_secs(5), exclusive, options)?; + + Ok(CacheLockGuard(lockfile)) + } + + #[cfg(not(test))] + fn get_time(&self) -> i64 { + proxmox_time::epoch_i64() + } + + #[cfg(test)] + fn get_time(&self) -> i64 { + self.time.get() + } + + #[cfg(test)] + fn set_time(&self, time: i64) { + self.time.set(time); + } + + fn enforce_safe_key(key: &str) -> Result<(), Error> { + let safe_id_regex = SAFE_ID_FORMAT.unwrap_pattern_format(); + if safe_id_regex.is_match(key) { + Ok(()) + } else { + bail!("invalid key format") + } + } + + fn get_entry_path(&self, key: &str) -> Result { + Self::enforce_safe_key(key)?; + let mut path = self.base_path.join(key); + path.set_extension("json"); + Ok(path) + } + + fn get_lockfile_path(&self, key: &str) -> Result { + Self::enforce_safe_key(key)?; + let mut path = self.base_path.join(key); + path.set_extension("lck"); + Ok(path) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CachedItem { + value: V, + added_at: i64, + expires_in: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn test_basic_set_and_get() { + let cache = TestCache::new(); + cache + .cache + .set("foo", &Value::String("bar".into()), None) + .unwrap(); + + assert_eq!( + cache.cache.get("foo").unwrap(), + Some(Value::String("bar".into())) + ); + assert!(cache.cache.get::<_, Value>("notthere").unwrap().is_none()); + } + + struct TestCache { + cache: SharedCache, + } + + impl TestCache { + fn new() -> Self { + let path = proxmox_sys::fs::make_tmp_dir("/tmp/", None).unwrap(); + + let options = CreateOptions::new() + .owner(nix::unistd::Uid::effective()) + .group(nix::unistd::Gid::effective()) + .perm(nix::sys::stat::Mode::from_bits_truncate(0o600)); + + let cache = SharedCache::new(&path, options).unwrap(); + Self { cache } + } + } + + impl Drop for TestCache { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.cache.base_path); + } + } + + #[test] + fn test_expiry() { + let wrapper = TestCache::new(); + + wrapper + .cache + .set("expiring", &Value::String("bar".into()), Some(10)) + .unwrap(); + assert!(wrapper.cache.get::<_, Value>("expiring").unwrap().is_some()); + + wrapper.cache.set_time(9); + assert!(wrapper.cache.get::<_, Value>("expiring").unwrap().is_some()); + wrapper.cache.set_time(11); + assert!(wrapper.cache.get::<_, Value>("expiring").unwrap().is_none()); + } + + #[test] + fn test_backwards_time_jump() { + let wrapper = TestCache::new(); + + wrapper.cache.set_time(50); + wrapper + .cache + .set("future", &Value::String("bar".into()), Some(10)) + .unwrap(); + wrapper.cache.set_time(30); + assert!(wrapper.cache.get::<_, Value>("future").unwrap().is_none()); + } + + #[test] + fn test_invalid_keys() { + let wrapper = TestCache::new(); + + assert!(wrapper + .cache + .set("../escape_base", &Value::Null, None) + .is_err()); + assert!(wrapper + .cache + .set("bjørnen drikker øl", &Value::Null, None) + .is_err()); + assert!(wrapper.cache.set("test space", &Value::Null, None).is_err()); + assert!(wrapper.cache.set("~/foo", &Value::Null, None).is_err()); + } + + #[test] + fn test_deletion() { + let wrapper = TestCache::new(); + + wrapper + .cache + .set("delete", &Value::String("bar".into()), Some(10)) + .unwrap(); + + assert!(wrapper.cache.delete("delete").is_ok()); + assert!(wrapper.cache.get::<_, Value>("delete").unwrap().is_none()); + } + + #[test] + fn test_get_or_update() { + let wrapper = TestCache::new(); + + let val = wrapper + .cache + .get_or_update("test", &mut || Ok(0), Some(5)) + .unwrap(); + + assert_eq!(val, 0); + + wrapper.cache.set_time(4); + let val = wrapper + .cache + .get_or_update("test", &mut || Ok(4), Some(5)) + .unwrap(); + assert_eq!(val, 0); + + wrapper.cache.set_time(6); + let val = wrapper + .cache + .get_or_update("test", &mut || Ok(6), Some(5)) + .unwrap(); + assert_eq!(val, 6); + } +} -- 2.39.2