Skip to content

Commit 12c3272

Browse files
committed
feat(common): Add a simple TTL cache implementation
1 parent 31e78c2 commit 12c3272

File tree

3 files changed

+156
-0
lines changed

3 files changed

+156
-0
lines changed

crates/matrix-sdk-common/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ All notable changes to this project will be documented in this file.
66

77
## [Unreleased] - ReleaseDate
88

9+
### Features
10+
11+
- Add a simple TTL cache implementation. The `TtlCache` struct can be used as a
12+
key/value map that expires items after 15 minutes.
13+
([#4663](https://github.com/matrix-org/matrix-rust-sdk/pull/4663))
14+
915
## [0.10.0] - 2025-02-04
1016

1117
- [**breaking**]: `SyncTimelineEvent` and `TimelineEvent` have been fused into a single type

crates/matrix-sdk-common/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ pub mod sleep;
3232
pub mod store_locks;
3333
pub mod timeout;
3434
pub mod tracing_timer;
35+
pub mod ttl_cache;
3536

3637
// We cannot currently measure test coverage in the WASM environment, so
3738
// js_tracing is incorrectly flagged as untested. Disable coverage checking for
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright 2025 The Matrix.org Foundation C.I.C.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
//! A TTL cache which can be used to time out repeated operations that might
16+
//! experience intermittent failures.
17+
18+
use std::{borrow::Borrow, collections::HashMap, hash::Hash, time::Duration};
19+
20+
use ruma::time::Instant;
21+
22+
// One day is the default lifetime.
23+
const DEFAULT_LIFETIME: Duration = Duration::from_secs(24 * 60 * 60);
24+
25+
#[derive(Debug)]
26+
struct TtlItem<V: Clone> {
27+
value: V,
28+
insertion_time: Instant,
29+
lifetime: Duration,
30+
}
31+
32+
impl<V: Clone> TtlItem<V> {
33+
fn expired(&self) -> bool {
34+
self.insertion_time.elapsed() >= self.lifetime
35+
}
36+
}
37+
38+
/// A TTL cache where items get removed deterministically in the `get()` call.
39+
#[derive(Debug)]
40+
pub struct TtlCache<K: Eq + Hash, V: Clone> {
41+
lifetime: Duration,
42+
items: HashMap<K, TtlItem<V>>,
43+
}
44+
45+
impl<K, V> TtlCache<K, V>
46+
where
47+
K: Eq + Hash,
48+
V: Clone,
49+
{
50+
/// Create a new, empty, [`TtlCache`].
51+
pub fn new() -> Self {
52+
Self { items: Default::default(), lifetime: DEFAULT_LIFETIME }
53+
}
54+
55+
/// Does the cache contain an non-expired item with the matching key.
56+
pub fn contains<Q>(&self, key: &Q) -> bool
57+
where
58+
K: Borrow<Q>,
59+
Q: Hash + Eq + ?Sized,
60+
{
61+
let cache = &self.items;
62+
let contains = if let Some(item) = cache.get(key) { !item.expired() } else { false };
63+
64+
contains
65+
}
66+
67+
/// Add a single item to the cache.
68+
pub fn insert(&mut self, key: K, value: V) {
69+
self.extend([(key, value)]);
70+
}
71+
72+
/// Extend the cache with the given iterator of items.
73+
pub fn extend(&mut self, iterator: impl IntoIterator<Item = (K, V)>) {
74+
let cache = &mut self.items;
75+
76+
let now = Instant::now();
77+
78+
for (key, value) in iterator {
79+
let item = TtlItem { value, insertion_time: now, lifetime: self.lifetime };
80+
81+
cache.insert(key, item);
82+
}
83+
}
84+
85+
/// Remove the item that matches the given key.
86+
pub fn remove<Q>(&mut self, key: &Q) -> Option<V>
87+
where
88+
K: Borrow<Q>,
89+
Q: Hash + Eq + ?Sized,
90+
{
91+
self.items.remove(key.borrow()).map(|item| item.value)
92+
}
93+
94+
/// Get the item that matches the given key, if the item has expired `None`
95+
/// will be returned and the item will be evicted from the cache.
96+
pub fn get<Q>(&mut self, key: &Q) -> Option<V>
97+
where
98+
K: Borrow<Q>,
99+
Q: Hash + Eq + ?Sized,
100+
{
101+
// Remove all expired items.
102+
self.items.retain(|_, value| !value.expired());
103+
// Now get the wanted item.
104+
self.items.get(key.borrow()).map(|item| item.value.clone())
105+
}
106+
107+
/// Force the expiry of the given item, if it is present in the cache.
108+
///
109+
/// This doesn't remove the item, it just marks it as expired.
110+
#[doc(hidden)]
111+
pub fn expire<Q>(&mut self, key: &Q)
112+
where
113+
K: Borrow<Q>,
114+
Q: Hash + Eq + ?Sized,
115+
{
116+
if let Some(item) = self.items.get_mut(key) {
117+
item.lifetime = Duration::from_secs(0);
118+
}
119+
}
120+
}
121+
122+
impl<K: Eq + Hash, V: Clone> Default for TtlCache<K, V> {
123+
fn default() -> Self {
124+
Self::new()
125+
}
126+
}
127+
128+
#[cfg(test)]
129+
mod tests {
130+
131+
use super::TtlCache;
132+
133+
#[test]
134+
fn test_ttl_cache_insertion() {
135+
let mut cache = TtlCache::new();
136+
assert!(!cache.contains("A"));
137+
138+
cache.insert("A", 1);
139+
assert!(cache.contains("A"));
140+
141+
let value = cache.get("A").expect("The value should be in the cache");
142+
assert_eq!(value, 1);
143+
144+
cache.expire("A");
145+
146+
assert!(!cache.contains("A"));
147+
assert!(cache.get("A").is_none(), "The item should have been removed from the cache");
148+
}
149+
}

0 commit comments

Comments
 (0)