[pve-devel] [PATCH v2 proxmox 5/7] cache: add new crate 'proxmox-shared-cache'

Lukas Wagner l.wagner at proxmox.com
Thu Sep 28 13:50:10 CEST 2023


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 <l.wagner at proxmox.com>
---
 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 <support at proxmox.com>  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 <!nocheck>,
+ rustc:native <!nocheck>,
+ libstd-rust-dev <!nocheck>,
+ librust-anyhow-1+default-dev <!nocheck>,
+ librust-nix-0.26+default-dev (>= 0.26.1-~~) <!nocheck>,
+ librust-proxmox-schema-2+api-types-dev <!nocheck>,
+ librust-proxmox-schema-2+default-dev <!nocheck>,
+ librust-proxmox-sys-0.5+default-dev <!nocheck>,
+ librust-proxmox-sys-0.5+timer-dev <!nocheck>,
+ librust-proxmox-time-1+default-dev (>= 1.1.4-~~) <!nocheck>,
+ librust-serde-1+default-dev <!nocheck>,
+ librust-serde-1+derive-dev <!nocheck>,
+ librust-serde-json-1+default-dev <!nocheck>,
+ librust-serde-json-1+raw-value-dev <!nocheck>
+Maintainer: Proxmox Support Team <support at proxmox.com>
+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 <support at proxmox.com>
+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 <https://www.gnu.org/licenses/>.
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 <support at proxmox.com>"
+
+[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<Value> = 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<Value> = 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<i64>,
+}
+
+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<P: AsRef<Path>>(base_path: P, options: CreateOptions) -> Result<Self, Error> {
+        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<S: AsRef<str>, V: Serialize>(
+        &self,
+        key: S,
+        value: &V,
+        expires_in: Option<i64>,
+    ) -> 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<S: AsRef<str>, V: Serialize>(
+        &self,
+        key: S,
+        value: &V,
+        expires_in: Option<i64>,
+        _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<S: AsRef<str>>(&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<S: AsRef<str>>(
+        &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<S: AsRef<str>, V: DeserializeOwned>(&self, key: S) -> Result<Option<V>, 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<S: AsRef<str>, V: DeserializeOwned>(
+        &self,
+        key: S,
+        _lock: &CacheLockGuard,
+    ) -> Result<Option<V>, 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<K, V, F>(
+        &self,
+        key: K,
+        value_func: &mut F,
+        expires_in: Option<i64>,
+    ) -> Result<V, Error>
+    where
+        K: AsRef<str>,
+        F: FnMut() -> Result<V, Error>,
+        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 `<base-path>/<key>.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<S: AsRef<str>>(&self, key: S, exclusive: bool) -> Result<CacheLockGuard, Error> {
+        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<PathBuf, Error> {
+        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<PathBuf, Error> {
+        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<V> {
+    value: V,
+    added_at: i64,
+    expires_in: Option<i64>,
+}
+
+#[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






More information about the pve-devel mailing list