1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
use super::state::State;
use crate::{
event::{self, EndpointPublisher as _},
path::secret::map::store::Store,
};
use rand::RngExt as _;
use s2n_quic_core::time;
use std::{
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex,
},
time::{Duration, Instant},
};
const EVICTION_CYCLES: u64 = if cfg!(test) { 0 } else { 10 };
/// The cleaner loop runs roughly once per this interval. Serialization periods are expressed as a
/// number of these cycles, and the access-time epoch advances once per cycle.
pub(super) const CLEANER_CYCLE: Duration = Duration::from_secs(60);
/// Converts a serialization `period` into a number of cleaner cycles (at least one).
///
/// The cleaner already runs on a jittered ~once-per-minute cadence, so rather than jittering the
/// serialization time separately we pick a random cycle within each window of this many cycles to
/// serialize in. This spreads writes across processes for free.
fn period_in_cycles(period: Duration) -> u64 {
(period.as_secs() / CLEANER_CYCLE.as_secs()).max(1)
}
pub struct Cleaner {
should_stop: AtomicBool,
thread: Mutex<Option<std::thread::JoinHandle<()>>>,
epoch: AtomicU64,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct Epoch(pub(crate) u64);
impl Epoch {
#[inline]
pub fn get(self) -> u64 {
self.0
}
pub(crate) fn duration_since(&self, base: Epoch) -> Duration {
let delta = self.0.saturating_sub(base.0);
CLEANER_CYCLE.saturating_mul(u32::try_from(delta).unwrap_or(u32::MAX))
}
}
impl Drop for Cleaner {
fn drop(&mut self) {
self.stop();
}
}
impl Cleaner {
pub fn new() -> Cleaner {
Cleaner {
should_stop: AtomicBool::new(false),
thread: Mutex::new(None),
epoch: AtomicU64::new(1),
}
}
pub fn stop(&self) {
self.should_stop.store(true, Ordering::Relaxed);
if let Some(thread) =
std::mem::take(&mut *self.thread.lock().unwrap_or_else(|e| e.into_inner()))
{
thread.thread().unpark();
// If this isn't getting dropped on the cleaner thread,
// then wait for the background thread to finish exiting.
if std::thread::current().id() != thread.thread().id() {
// We expect this to terminate very quickly.
#[expect(
clippy::unwrap_used,
reason = "panicking if a panic already occurred is OK"
)]
thread.join().unwrap();
}
}
}
#[expect(clippy::unwrap_in_result)]
pub fn spawn_thread<C, S>(&self, state: Arc<State<C, S>>) -> std::io::Result<()>
where
C: 'static + time::Clock + Send + Sync,
S: event::Subscriber,
{
// check to see if we're in a simulation before spawning a thread
#[cfg(any(test, feature = "testing"))]
if bach::is_active() {
return Ok(());
}
// The serialization period, expressed in cleaner cycles, if configured. The cleaner drives
// serialization so we don't need a second background thread.
let serialize_cycles = state
.serializer
.as_ref()
.and_then(|s| s.period())
.map(period_in_cycles);
let state = Arc::downgrade(&state);
let handle = std::thread::Builder::new()
.name("dc_quic::cleaner".into())
.spawn(move || {
// We serialize at most once per `serialize_cycles` window. `cycle` counts cleaner
// runs within the current window (wrapping back to 0 at the window boundary) and
// `serialize_at` is the randomly-chosen cycle within the window in which we write.
// Re-rolled each window so the cleaner's own jitter spreads writes across
// processes without a separate timer.
let mut cycle = 0u64;
let mut serialize_at =
serialize_cycles.map(|cycles| rand::rng().random_range(0..cycles));
loop {
// in tests, we should try and be as deterministic as possible
let pause = if cfg!(test) {
CLEANER_CYCLE.as_millis() as u64
} else {
rand::rng().random_range(
Duration::from_secs(5).as_millis() as u64
..CLEANER_CYCLE.as_millis() as u64,
)
};
let next_start = Instant::now() + CLEANER_CYCLE;
std::thread::park_timeout(Duration::from_millis(pause));
let Some(state) = state.upgrade() else {
break;
};
if state.cleaner().should_stop.load(Ordering::Relaxed) {
break;
}
state.cleaner().clean(&state, EVICTION_CYCLES);
// Serialize the map in the chosen cycle of each window, then advance the cycle
// counter and re-roll at the window boundary.
if let Some(cycles) = serialize_cycles {
if Some(cycle) == serialize_at {
if let Err(err) = state.serialize_to_disk() {
tracing::warn!(%err, "failed to serialize path secret map to disk");
}
}
cycle += 1;
if cycle >= cycles {
cycle = 0;
serialize_at = Some(rand::rng().random_range(0..cycles));
}
}
// pause the rest of the time to run once a minute, not twice a minute
std::thread::park_timeout(next_start.saturating_duration_since(Instant::now()));
}
});
let handle = handle?;
#[expect(clippy::unwrap_used, reason = "panic only if already panicked")]
{
*self.thread.lock().unwrap() = Some(handle);
}
Ok(())
}
/// Periodic maintenance for various maps.
pub fn clean<C, S>(&self, state: &State<C, S>, eviction_cycles: u64)
where
C: 'static + time::Clock + Send + Sync,
S: event::Subscriber,
{
let start = state.clock.get_time();
let current_epoch = self.epoch.fetch_add(1, Ordering::Relaxed);
let utilization = |count: usize| (count as f32 / state.secrets_capacity() as f32) * 100.0;
let id_entries_initial = state.ids.len();
let mut id_entries_retired = 0usize;
let mut id_entries_active = 0usize;
let address_entries_initial = state.peers.len();
let mut address_entries_retired = 0usize;
let mut address_entries_active = 0usize;
// Entries created within the last rehandshake period. This tracks how much of the cache
// churns over a single rehandshake period, which is useful for understanding capacity
// pressure relative to the rate at which we re-handshake peers.
let mut id_entries_in_last_hs_period = 0usize;
// We want to avoid taking long lived locks which affect gets on the maps (where we want
// p100 latency to be in microseconds at most).
//
// Impeding *handshake* latency is much more acceptable though since this happens at most
// once a minute and handshakes are of similar magnitude (~milliseconds/handshake, this is
// also expected to run for single digit milliseconds).
//
// Note that we expect the queue to be an exhaustive list of entries - no entry should not
// be in the queue but be in the maps for more than a few microseconds during a handshake
// (when we pop from the queue to remove from the maps).
let mut queue = state
.eviction_queue
.lock()
.unwrap_or_else(|e| e.into_inner());
let in_lock = state.clock.get_time();
// This map is only accessed with queue lock held and in cleaner, so it is in practice
// single threaded. No concurrent access is permitted.
state.cleaner_peer_seen.clear();
#[expect(
clippy::unwrap_used,
reason = "lock is only poisoned if another thread already panicked while holding it"
)]
let mut rehandshake = state.rehandshake.lock().unwrap();
let refill_rehandshakes = rehandshake.needs_refill();
// Compute the cutoff once (rather than calling `Instant::now()` per entry via `age()`) so
// we can cheaply compare each entry's creation timestamp directly. Entries created at or
// after this cutoff are considered to have been created within the last rehandshake period.
let recent_cutoff = Instant::now().checked_sub(rehandshake.rehandshake_period());
// FIXME: add metrics for queue depth?
// These are sort of equivalent to the ID map -- so maybe not worth it for now unless we
// can get something more interesting out of it.
queue.retain(|entry| {
let Some(entry) = entry.upgrade() else {
return false;
};
// Entries created within the last rehandshake period. If subtraction underflowed
// (period exceeds process uptime), all entries are considered recent.
let in_rehandshake_period =
recent_cutoff.is_none_or(|cutoff| entry.creation_time() >= cutoff);
if entry.take_accessed_id() {
id_entries_active += 1;
}
if in_rehandshake_period {
id_entries_in_last_hs_period += 1;
}
// Avoid double counting by making sure we have unique peer IPs.
// We clear/take the accessed bit regardless of whether we're going to count it to
// preserve the property that every cleaner run snapshots last ~minute.
if entry.take_accessed_addr()
&& state
.cleaner_peer_seen
.insert_no_events(entry.clone())
.is_none()
{
address_entries_active += 1;
}
if refill_rehandshakes {
// We'll dedup after we fill, we preallocate for the max capacity so this shouldn't
// allocate in practice.
rehandshake.push(*entry.peer());
}
let retained = if let Some(retired_at) = entry.retired_at() {
// retain if we aren't yet ready to evict.
current_epoch.saturating_sub(retired_at.get()) < eviction_cycles
} else {
// always retain non-retired entries.
true
};
if !retained {
let (id_removed, peer_removed) =
state.evict(&entry, event::builder::EvictionReason::Retiring);
if id_removed {
id_entries_retired += 1;
}
if peer_removed {
address_entries_retired += 1;
}
return false;
}
true
});
// Avoid retaining entries for longer than expected.
state.cleaner_peer_seen.clear();
drop(queue);
let handshake_lock_duration = state.clock.get_time().saturating_duration_since(in_lock);
if refill_rehandshakes {
rehandshake.adjust_post_refill();
}
let mut handshake_requests = 0;
let handshake_requests_skipped =
rehandshake.next_rehandshake_batch(state.peers.len(), |peer| {
handshake_requests += 1;
state.request_handshake(peer, crate::psk::io::HandshakeReason::Periodic)
});
drop(rehandshake);
let id_entries = state.ids.len();
let address_entries = state.peers.len();
state.subscriber().on_path_secret_map_cleaner_cycled(
event::builder::PathSecretMapCleanerCycled {
id_entries,
id_entries_retired,
id_entries_active,
id_entries_active_utilization: utilization(id_entries_active),
id_entries_utilization: utilization(id_entries),
id_entries_initial_utilization: utilization(id_entries_initial),
address_entries,
address_entries_active,
address_entries_active_utilization: utilization(address_entries_active),
address_entries_utilization: utilization(address_entries),
address_entries_initial_utilization: utilization(address_entries_initial),
address_entries_retired,
id_entries_in_last_hs_period,
id_entries_in_last_hs_period_utilization: utilization(id_entries_in_last_hs_period),
handshake_requests,
handshake_requests_skipped,
handshake_lock_duration,
duration: state.clock.get_time().saturating_duration_since(start),
},
);
}
pub fn epoch(&self) -> Epoch {
Epoch(self.epoch.load(Ordering::Relaxed))
}
}