regolith 0.1.1

ACID, performance oriented, embedded key-value database engine for edge systems
Documentation
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
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
//! Token-bucket rate limiter for background I/O.
//!
//! A [`RateLimiter`] throttles byte-denominated work (flush and
//! compaction writes) so bursts of background I/O don't saturate the
//! disk and push foreground latency off a cliff. The engine calls
//! [`RateLimiter::request`] with a byte count and a [`Priority`]; the
//! call blocks until enough tokens have accumulated, then returns.
//!
//! [`TokenBucketRateLimiter`] is the stock implementation: a single
//! token bucket with a configurable refill rate and burst capacity,
//! served by a FIFO queue that always drains [`Priority::High`] waiters
//! before [`Priority::Low`]. It is the only implementation regolith ships;
//! the trait is public so callers can drop in their own (e.g. for test
//! harnesses or a shared limiter across multiple databases).

use crate::portability::{AtomicU64, Ordering};
use std::collections::BTreeSet;
use std::time::Duration;

// The module's own tests measure real elapsed time to prove the
// limiter actually blocks.
#[cfg(test)]
use std::time::Instant;

use crate::sync::{Condvar, Mutex};

/// Priority of a rate-limited I/O request. High-priority waiters are
/// always served before low-priority waiters; within a priority class
/// waiters are served in FIFO order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Priority {
    /// Foreground work. Served first.
    High,
    /// Background work (flush, compaction). Yields to [`Priority::High`].
    Low,
}

impl Priority {
    fn class(self) -> u8 {
        match self {
            Priority::High => 0,
            Priority::Low => 1,
        }
    }
}

/// A byte-denominated rate limiter.
///
/// Implementations throttle callers of [`RateLimiter::request`] to at
/// most `get_bytes_per_second()` bytes over time. Requests block until
/// quota is available; shutdown (on drop or via an implementation-
/// specific `stop()` call) must wake every blocked waiter.
pub trait RateLimiter: Send + Sync + 'static {
    /// Request `bytes` worth of I/O quota. Blocks until the request is
    /// served or the limiter is shut down.
    fn request(&self, bytes: u64, pri: Priority);

    /// Update the refill rate. Takes effect on the next refill tick.
    fn set_bytes_per_second(&self, bytes_per_second: u64);

    /// Return the currently configured refill rate in bytes/sec.
    fn get_bytes_per_second(&self) -> u64;

    /// Total bytes successfully served at `pri` since construction.
    /// Excludes in-flight requests.
    fn get_total_bytes_through(&self, pri: Priority) -> u64;
}

/// Internal waiter identity: priority class first so the high class
/// sorts before the low class, seq second for FIFO within a class.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct WaiterKey {
    class: u8,
    seq: u64,
}

struct State {
    bytes_per_second: u64,
    available: i128,
    /// Nanoseconds from the platform clock at the last refill.
    last_refill: u64,
    next_seq: u64,
    waiters: BTreeSet<WaiterKey>,
    shutdown: bool,
}

/// Default rate-limiter implementation: a single token bucket refilled
/// at `bytes_per_second` bytes/sec with a burst capacity of `burst_bytes`.
///
/// All waiters share one mutex and one condition variable; on wakeup, each
/// waiter checks whether it is at the front of the FIFO queue (highest
/// priority, lowest seq) and, if so, whether enough tokens have
/// accumulated. Waiters that aren't at the front simply go back to
/// sleep.
///
/// `refill_period` controls the granularity at which tokens are
/// credited to the bucket: a smaller period smooths bursts at the cost
/// of more wakeups; a larger period is cheaper but chunkier. 100 ms is
/// a reasonable starting point for most workloads.
pub struct TokenBucketRateLimiter {
    state: Mutex<State>,
    cv: Condvar,
    burst_bytes: u64,
    refill_period: Duration,
    total_high: AtomicU64,
    total_low: AtomicU64,
}

impl TokenBucketRateLimiter {
    /// Construct a new limiter.
    ///
    /// * `bytes_per_second` - sustained refill rate. `0` disables the
    ///   limiter (every request is served instantly).
    /// * `refill_period` - how often tokens are credited. A zero
    ///   period credits tokens continuously: every request refills
    ///   the bucket for exactly the time that has elapsed.
    /// * `burst_bytes` - maximum number of tokens the bucket can hold.
    ///   A fresh bucket starts full so the first `burst_bytes` worth of
    ///   work is served without blocking. Clamped to at least 1.
    ///
    /// Never panics: out-of-range arguments are clamped rather than
    /// asserted, because this is a public constructor.
    pub fn new(bytes_per_second: u64, refill_period: Duration, burst_bytes: u64) -> Self {
        let burst_bytes = burst_bytes.max(1);
        Self {
            state: Mutex::new(State {
                bytes_per_second,
                available: burst_bytes as i128,
                last_refill: crate::env::platform_nanos().unwrap_or(0),
                next_seq: 0,
                waiters: BTreeSet::new(),
                shutdown: false,
            }),
            cv: Condvar::new(),
            burst_bytes,
            refill_period,
            total_high: AtomicU64::new(0),
            total_low: AtomicU64::new(0),
        }
    }

    /// Wake every blocked waiter and return. Subsequent calls to
    /// [`RateLimiter::request`] also return immediately without
    /// consuming tokens. Called automatically when the limiter is
    /// dropped.
    pub fn stop(&self) {
        let mut state = self.state.lock();
        state.shutdown = true;
        self.cv.notify_all();
    }

    /// Refill the bucket based on elapsed time since `last_refill`.
    /// Caller holds the lock.
    fn refill_locked(&self, state: &mut State, now: u64) {
        let elapsed = u128::from(now.saturating_sub(state.last_refill));
        if elapsed < self.refill_period.as_nanos() {
            return;
        }
        let period_nanos: u128 = self.refill_period.as_nanos().max(1);
        let periods = (elapsed / period_nanos) as u64;
        if periods == 0 {
            return;
        }
        // tokens = rate * (periods * refill_period)
        let rate = state.bytes_per_second as u128;
        let tokens = rate
            .saturating_mul(period_nanos)
            .saturating_mul(periods as u128)
            / 1_000_000_000u128;
        state.available = (state.available + tokens as i128).min(self.burst_bytes as i128);
        state.last_refill = state
            .last_refill
            .saturating_add((period_nanos.saturating_mul(periods as u128)) as u64);
    }

    /// Internal: serve one chunk (at most `burst_bytes`).
    fn request_chunk(&self, bytes: u64, pri: Priority) -> bool {
        if bytes == 0 {
            return true;
        }
        // Rate limiting is a function of elapsed time. A platform
        // with no monotonic clock cannot measure it, so the limiter
        // serves every request immediately instead of blocking on a
        // bucket that could never refill.
        let Some(mut now) = crate::env::platform_nanos() else {
            return true;
        };
        let class = pri.class();
        let mut state = self.state.lock();
        if state.shutdown {
            return false;
        }
        let my_seq = state.next_seq;
        state.next_seq += 1;
        let key = WaiterKey { class, seq: my_seq };
        state.waiters.insert(key);

        let served = loop {
            if state.shutdown {
                break false;
            }

            self.refill_locked(&mut state, now);

            let Some(front) = state.waiters.iter().next().copied() else {
                break false;
            };
            if front == key && state.available >= bytes as i128 {
                state.available -= bytes as i128;
                break true;
            }

            // Either we aren't at the front or tokens aren't ready.
            // Compute how long until the next refill and sleep that
            // long - a spurious wakeup just re-enters the loop.
            now = crate::env::platform_nanos().unwrap_or(now);
            let wait = self
                .refill_period
                .saturating_sub(Duration::from_nanos(now.saturating_sub(state.last_refill)));
            let wait = if wait.is_zero() {
                self.refill_period
            } else {
                wait
            };
            state = self
                .cv
                .wait_timeout(state, wait)
                .unwrap_or_else(std::sync::PoisonError::into_inner)
                .0;
        };

        state.waiters.remove(&key);
        // Front of queue may have changed; wake the new front.
        self.cv.notify_all();

        if served {
            match pri {
                Priority::High => {
                    self.total_high.fetch_add(bytes, Ordering::Relaxed);
                }
                Priority::Low => {
                    self.total_low.fetch_add(bytes, Ordering::Relaxed);
                }
            }
        }
        served
    }
}

impl Drop for TokenBucketRateLimiter {
    fn drop(&mut self) {
        // Dropping doesn't actually wake external waiters (they hold
        // &self), but `stop()` is idempotent and flags state.shutdown
        // for any future request calls that race the drop.
        self.stop();
    }
}

impl RateLimiter for TokenBucketRateLimiter {
    fn request(&self, bytes: u64, pri: Priority) {
        if self.get_bytes_per_second() == 0 {
            match pri {
                Priority::High => {
                    self.total_high.fetch_add(bytes, Ordering::Relaxed);
                }
                Priority::Low => {
                    self.total_low.fetch_add(bytes, Ordering::Relaxed);
                }
            }
            return;
        }
        let mut remaining = bytes;
        while remaining > 0 {
            let chunk = remaining.min(self.burst_bytes);
            if !self.request_chunk(chunk, pri) {
                // Shutdown: stop trying.
                return;
            }
            remaining -= chunk;
        }
    }

    fn set_bytes_per_second(&self, bytes_per_second: u64) {
        let mut state = self.state.lock();
        // Catch up on any pending refill at the old rate before
        // switching, so the change takes effect cleanly from "now".
        if let Some(now) = crate::env::platform_nanos() {
            self.refill_locked(&mut state, now);
        }
        state.bytes_per_second = bytes_per_second;
        self.cv.notify_all();
    }

    fn get_bytes_per_second(&self) -> u64 {
        self.state.lock().bytes_per_second
    }

    fn get_total_bytes_through(&self, pri: Priority) -> u64 {
        match pri {
            Priority::High => self.total_high.load(Ordering::Relaxed),
            Priority::Low => self.total_low.load(Ordering::Relaxed),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Arc;
    use std::thread;

    #[test]
    fn single_request_within_burst_is_instant() {
        let lim = TokenBucketRateLimiter::new(1_000_000, Duration::from_millis(100), 1_000_000);
        let start = Instant::now();
        lim.request(500_000, Priority::Low);
        assert!(start.elapsed() < Duration::from_millis(50));
        assert_eq!(lim.get_total_bytes_through(Priority::Low), 500_000);
    }

    #[test]
    fn ten_mb_through_one_mbps_takes_at_least_nine_seconds() {
        // The bucket starts full (1 MB burst) so a 10 MB request
        // sees 1 MB of free credit up front - expected wait is
        // ~9 seconds, not 10. Assert >= 9 to match.
        let lim = TokenBucketRateLimiter::new(1_000_000, Duration::from_millis(100), 1_000_000);
        let start = Instant::now();
        lim.request(10_000_000, Priority::Low);
        let elapsed = start.elapsed();
        assert!(
            elapsed >= Duration::from_secs(9),
            "10 MB through 1 MB/s took {:?}, expected >= 9s",
            elapsed
        );
        assert_eq!(lim.get_total_bytes_through(Priority::Low), 10_000_000);
    }

    #[test]
    fn high_priority_preempts_low() {
        // Tune the bucket so every waiter needs several refill
        // periods' worth of tokens: that gives us a wide window to
        // inject a high-priority request into the queue while low-
        // priority waiters are still blocked, and lets the priority
        // order determine who drains each refill.
        //
        // rate:          100_000 bytes/sec
        // refill_period: 100 ms  → +10_000 bytes per period
        // burst:         10_000 bytes
        //
        // Each waiter asks for 30_000 bytes, i.e. three refills'
        // worth of credit.
        let lim = Arc::new(TokenBucketRateLimiter::new(
            100_000,
            Duration::from_millis(100),
            10_000,
        ));

        // Drain the initial burst so every following waiter has to
        // queue for refills.
        lim.request(10_000, Priority::Low);

        let order: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));

        let low_handles: Vec<_> = (0..2)
            .map(|i| {
                let lim = lim.clone();
                let order = order.clone();
                thread::spawn(move || {
                    lim.request(30_000, Priority::Low);
                    let label = if i == 0 { "lo1" } else { "lo2" };
                    order.lock().push(label);
                })
            })
            .collect();

        // Brief sleep to ensure both low waiters have registered
        // in the BTreeSet before we enqueue the high-priority one.
        // Well below one refill_period so no low waiter can have
        // been served yet.
        thread::sleep(Duration::from_millis(30));

        let high = {
            let lim = lim.clone();
            let order = order.clone();
            thread::spawn(move || {
                lim.request(30_000, Priority::High);
                order.lock().push("high");
            })
        };

        high.join().unwrap();
        for h in low_handles {
            h.join().unwrap();
        }

        let order = order.lock();
        let high_idx = order.iter().position(|&s| s == "high").unwrap();
        // High must land strictly before at least one low waiter
        // despite arriving last in wall-clock order.
        assert!(
            high_idx < 2,
            "high did not preempt any low: order = {:?}",
            *order
        );
    }

    #[test]
    fn shutdown_wakes_blocked_waiters() {
        let lim = Arc::new(TokenBucketRateLimiter::new(
            1_000,
            Duration::from_secs(60),
            1_000,
        ));
        // Drain the burst so the next request has to wait ~60s.
        lim.request(1_000, Priority::Low);

        let blocked = {
            let lim = lim.clone();
            thread::spawn(move || {
                let start = Instant::now();
                lim.request(1_000, Priority::Low);
                start.elapsed()
            })
        };

        thread::sleep(Duration::from_millis(100));
        lim.stop();
        let waited = blocked.join().unwrap();
        assert!(
            waited < Duration::from_secs(5),
            "blocked waiter did not wake promptly after stop: {:?}",
            waited
        );
    }

    #[test]
    fn set_bytes_per_second_live_update_is_respected() {
        let lim = Arc::new(TokenBucketRateLimiter::new(
            100_000,
            Duration::from_millis(50),
            100_000,
        ));
        lim.request(100_000, Priority::Low); // drain burst
        assert_eq!(lim.get_bytes_per_second(), 100_000);

        // Bump the rate and confirm a subsequent large request
        // completes faster than it would have at the old rate.
        lim.set_bytes_per_second(10_000_000);
        assert_eq!(lim.get_bytes_per_second(), 10_000_000);
        let start = Instant::now();
        lim.request(1_000_000, Priority::Low);
        // At the old rate 1 MB would need ~10 seconds; at 10 MB/s
        // it should need ~100 ms. Give it generous slack for CI.
        assert!(
            start.elapsed() < Duration::from_secs(2),
            "request took {:?} after rate bump",
            start.elapsed()
        );
    }

    #[test]
    fn zero_rate_disables_limiter() {
        let lim = TokenBucketRateLimiter::new(0, Duration::from_millis(100), 1);
        let start = Instant::now();
        lim.request(100_000_000, Priority::Low);
        assert!(start.elapsed() < Duration::from_millis(50));
        assert_eq!(lim.get_total_bytes_through(Priority::Low), 100_000_000);
    }

    #[test]
    fn get_total_bytes_through_tracks_both_classes() {
        let lim = TokenBucketRateLimiter::new(10_000_000, Duration::from_millis(50), 10_000_000);
        lim.request(1_000, Priority::High);
        lim.request(2_000, Priority::Low);
        lim.request(3_000, Priority::High);
        assert_eq!(lim.get_total_bytes_through(Priority::High), 4_000);
        assert_eq!(lim.get_total_bytes_through(Priority::Low), 2_000);
    }
}