all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Lukas Wagner <l.wagner@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH v2 proxmox 5/7] cache: add new crate 'proxmox-shared-cache'
Date: Thu, 28 Sep 2023 13:50:10 +0200	[thread overview]
Message-ID: <20230928115012.326777-6-l.wagner@proxmox.com> (raw)
In-Reply-To: <20230928115012.326777-1-l.wagner@proxmox.com>

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@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@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@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@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@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





  parent reply	other threads:[~2023-09-28 11:50 UTC|newest]

Thread overview: 8+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2023-09-28 11:50 [pve-devel] [PATCH v2 storage/proxmox{, -perl-rs} 0/7] cache storage plugin status for pvestatd/API status update calls Lukas Wagner
2023-09-28 11:50 ` [pve-devel] [PATCH v2 proxmox 1/7] sys: fs: remove unnecessary clippy allow directive Lukas Wagner
2023-09-28 11:50 ` [pve-devel] [PATCH v2 proxmox 2/7] sys: fs: let CreateOptions::apply_to take RawFd instead of File Lukas Wagner
2023-09-28 11:50 ` [pve-devel] [PATCH v2 proxmox 3/7] sys: fs: use inline formatting for bail! macro Lukas Wagner
2023-09-28 11:50 ` [pve-devel] [PATCH v2 proxmox 4/7] sys: add make_tmp_dir Lukas Wagner
2023-09-28 11:50 ` Lukas Wagner [this message]
2023-09-28 11:50 ` [pve-devel] [PATCH v2 proxmox-perl-rs 6/7] cache: add bindings for `SharedCache` Lukas Wagner
2023-09-28 11:50 ` [pve-devel] [PATCH v2 pve-storage 7/7] stats: api: cache storage plugin status Lukas Wagner

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20230928115012.326777-6-l.wagner@proxmox.com \
    --to=l.wagner@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal