Skip to content

Commit d8c47f9

Browse files
committed
fix(gateway): refresh WireGuard handshakes via cached cell
1 parent 8777b9b commit d8c47f9

7 files changed

Lines changed: 449 additions & 41 deletions

File tree

Cargo.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ members = [
5151
"key-provider-client",
5252
"dstack-types",
5353
"cert-client",
54+
"cached-cell",
5455
"lspci",
5556
"sodiumbox",
5657
"serde-duration",
@@ -97,6 +98,7 @@ load_config = { path = "load_config" }
9798
key-provider-client = { path = "key-provider-client" }
9899
dstack-types = { path = "dstack-types" }
99100
cert-client = { path = "cert-client" }
101+
cached-cell = { path = "cached-cell" }
100102
lspci = { path = "lspci" }
101103
sodiumbox = { path = "sodiumbox" }
102104
serde-duration = { path = "serde-duration" }

cached-cell/Cargo.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# SPDX-FileCopyrightText: © 2026 Phala Network <dstack@phala.network>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
[package]
6+
name = "cached-cell"
7+
version.workspace = true
8+
authors.workspace = true
9+
edition.workspace = true
10+
license.workspace = true
11+
12+
[dependencies]
13+
tokio = { workspace = true, features = ["rt", "time"] }
14+
15+
[dev-dependencies]
16+
tokio = { workspace = true, features = ["macros", "rt", "time"] }

cached-cell/src/lib.rs

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
// SPDX-FileCopyrightText: © 2026 Phala Network <dstack@phala.network>
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
//! A small `OnceCell`-like cache cell for values that are refreshed by a
6+
//! caller-provided blocking producer.
7+
//!
8+
//! The cell owns the common mechanics: snapshot storage, TTL checks,
9+
//! `spawn_blocking` refreshes, and optional periodic refresh scheduling. The
10+
//! caller owns domain-specific value generation and error handling.
11+
12+
use std::{
13+
error::Error,
14+
fmt,
15+
sync::{
16+
atomic::{AtomicBool, Ordering},
17+
Arc, RwLock,
18+
},
19+
time::{Duration, Instant},
20+
};
21+
22+
/// A TTL-bound cell containing the latest successfully produced value.
23+
pub struct TtlCell<T> {
24+
ttl: Duration,
25+
entry: RwLock<Option<Entry<T>>>,
26+
refresh_task_started: AtomicBool,
27+
}
28+
29+
struct Entry<T> {
30+
value: Arc<T>,
31+
refreshed_at: Instant,
32+
}
33+
34+
impl<T> Clone for Entry<T> {
35+
fn clone(&self) -> Self {
36+
Self {
37+
value: Arc::clone(&self.value),
38+
refreshed_at: self.refreshed_at,
39+
}
40+
}
41+
}
42+
43+
/// A point-in-time view of a cached value.
44+
#[derive(Debug)]
45+
pub struct Snapshot<T> {
46+
value: Arc<T>,
47+
refreshed_at: Instant,
48+
age: Duration,
49+
}
50+
51+
impl<T> Clone for Snapshot<T> {
52+
fn clone(&self) -> Self {
53+
Self {
54+
value: Arc::clone(&self.value),
55+
refreshed_at: self.refreshed_at,
56+
age: self.age,
57+
}
58+
}
59+
}
60+
61+
impl<T> Snapshot<T> {
62+
pub fn value(&self) -> &T {
63+
&self.value
64+
}
65+
66+
pub fn into_value(self) -> Arc<T> {
67+
self.value
68+
}
69+
70+
pub fn refreshed_at(&self) -> Instant {
71+
self.refreshed_at
72+
}
73+
74+
pub fn age(&self) -> Duration {
75+
self.age
76+
}
77+
}
78+
79+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80+
pub enum GetError {
81+
Empty,
82+
Expired { age: Duration, ttl: Duration },
83+
}
84+
85+
impl fmt::Display for GetError {
86+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87+
match self {
88+
Self::Empty => write!(f, "cached cell is empty"),
89+
Self::Expired { age, ttl } => {
90+
write!(f, "cached cell value expired: age={age:?}, ttl={ttl:?}")
91+
}
92+
}
93+
}
94+
}
95+
96+
impl Error for GetError {}
97+
98+
#[derive(Debug)]
99+
pub enum RefreshError<E> {
100+
Join(tokio::task::JoinError),
101+
Produce(E),
102+
}
103+
104+
impl<E: fmt::Display> fmt::Display for RefreshError<E> {
105+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106+
match self {
107+
Self::Join(err) => write!(f, "blocking refresh task failed: {err}"),
108+
Self::Produce(err) => write!(f, "cached cell producer failed: {err}"),
109+
}
110+
}
111+
}
112+
113+
impl<E> Error for RefreshError<E>
114+
where
115+
E: Error + Send + Sync + 'static,
116+
{
117+
fn source(&self) -> Option<&(dyn Error + 'static)> {
118+
match self {
119+
Self::Join(err) => Some(err),
120+
Self::Produce(err) => Some(err),
121+
}
122+
}
123+
}
124+
125+
impl<T> TtlCell<T> {
126+
pub fn new(ttl: Duration) -> Self {
127+
Self {
128+
ttl,
129+
entry: RwLock::new(None),
130+
refresh_task_started: AtomicBool::new(false),
131+
}
132+
}
133+
134+
pub fn ttl(&self) -> Duration {
135+
self.ttl
136+
}
137+
138+
/// Returns the cached value only if it has not expired.
139+
pub fn get(&self) -> Result<Snapshot<T>, GetError> {
140+
let snapshot = self.get_allow_stale()?;
141+
if snapshot.age() >= self.ttl {
142+
return Err(GetError::Expired {
143+
age: snapshot.age(),
144+
ttl: self.ttl,
145+
});
146+
}
147+
Ok(snapshot)
148+
}
149+
150+
/// Returns the last cached value even if it is older than the TTL.
151+
pub fn get_allow_stale(&self) -> Result<Snapshot<T>, GetError> {
152+
let entry = match self.entry.read() {
153+
Ok(entry) => entry,
154+
Err(poisoned) => poisoned.into_inner(),
155+
}
156+
.clone()
157+
.ok_or(GetError::Empty)?;
158+
Ok(snapshot(entry))
159+
}
160+
161+
pub fn set(&self, value: T) -> Snapshot<T> {
162+
let entry = Entry {
163+
value: Arc::new(value),
164+
refreshed_at: Instant::now(),
165+
};
166+
let mut current = match self.entry.write() {
167+
Ok(entry) => entry,
168+
Err(poisoned) => poisoned.into_inner(),
169+
};
170+
*current = Some(entry.clone());
171+
snapshot(entry)
172+
}
173+
}
174+
175+
impl<T> TtlCell<T>
176+
where
177+
T: Send + Sync + 'static,
178+
{
179+
/// Runs a blocking producer on Tokio's blocking pool and stores the result.
180+
pub async fn refresh_blocking<F, E>(&self, producer: F) -> Result<Snapshot<T>, RefreshError<E>>
181+
where
182+
F: FnOnce() -> Result<T, E> + Send + 'static,
183+
E: Send + 'static,
184+
{
185+
let value = tokio::task::spawn_blocking(producer)
186+
.await
187+
.map_err(RefreshError::Join)?
188+
.map_err(RefreshError::Produce)?;
189+
Ok(self.set(value))
190+
}
191+
192+
/// Starts one periodic refresh task. Returns `false` if already started.
193+
pub fn spawn_refresh_task<F, E, H>(
194+
self: Arc<Self>,
195+
interval: Duration,
196+
producer: F,
197+
on_error: H,
198+
) -> bool
199+
where
200+
F: Fn() -> Result<T, E> + Send + Sync + 'static,
201+
E: Send + 'static,
202+
H: Fn(RefreshError<E>) + Send + Sync + 'static,
203+
{
204+
if self.refresh_task_started.swap(true, Ordering::Relaxed) {
205+
return false;
206+
}
207+
208+
let producer = Arc::new(producer);
209+
let on_error = Arc::new(on_error);
210+
tokio::spawn(async move {
211+
loop {
212+
let producer = Arc::clone(&producer);
213+
if let Err(err) = self.refresh_blocking(move || producer()).await {
214+
on_error(err);
215+
}
216+
tokio::time::sleep(interval).await;
217+
}
218+
});
219+
true
220+
}
221+
}
222+
223+
fn snapshot<T>(entry: Entry<T>) -> Snapshot<T> {
224+
Snapshot {
225+
age: entry.refreshed_at.elapsed(),
226+
refreshed_at: entry.refreshed_at,
227+
value: entry.value,
228+
}
229+
}
230+
231+
#[cfg(test)]
232+
mod tests {
233+
use super::*;
234+
235+
#[test]
236+
fn returns_empty_before_first_set() {
237+
let cell = TtlCell::<u32>::new(Duration::from_secs(1));
238+
assert_eq!(cell.get().unwrap_err(), GetError::Empty);
239+
}
240+
241+
#[test]
242+
fn returns_cached_value() {
243+
let cell = TtlCell::new(Duration::from_secs(1));
244+
cell.set(42);
245+
assert_eq!(*cell.get().unwrap().value(), 42);
246+
}
247+
248+
#[test]
249+
fn enforces_ttl() {
250+
let cell = TtlCell::new(Duration::ZERO);
251+
cell.set(42);
252+
assert!(matches!(cell.get(), Err(GetError::Expired { .. })));
253+
assert_eq!(*cell.get_allow_stale().unwrap().value(), 42);
254+
}
255+
256+
#[tokio::test]
257+
async fn refreshes_with_blocking_producer() {
258+
let cell = TtlCell::new(Duration::from_secs(1));
259+
let snapshot = cell.refresh_blocking(|| Ok::<_, ()>(7)).await.unwrap();
260+
assert_eq!(*snapshot.value(), 7);
261+
assert_eq!(*cell.get().unwrap().value(), 7);
262+
}
263+
}

gateway/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ rmp-serde.workspace = true
6363
or-panic.workspace = true
6464
base64.workspace = true
6565
subtle.workspace = true
66+
cached-cell.workspace = true
6667

6768
[target.'cfg(unix)'.dependencies]
6869
nix = { workspace = true, features = ["resource"] }

0 commit comments

Comments
 (0)