Skip to main content

dynamic_config/watch/
options.rs

1//! The three numbers a file watch runs on.
2//!
3//! All three already governed every watcher; two of them were simply not
4//! reachable. The debounce was an argument, the ceiling was `debounce × 4`
5//! written into the loop, and the atomic-save grace was a process-wide
6//! setter — which is the right default and the wrong granularity for a
7//! program watching one configuration on a local disk and another on a
8//! network mount.
9
10use core::time::Duration;
11
12/// How a file watch waits.
13///
14/// ```text
15/// event ──▶ ┌─ debounce ─┐ quiet? ──▶ grace ──▶ read
16///           └─ restarted by every further event ─┘
17///                        └─ but never past the ceiling ─┘
18/// ```
19///
20/// - **`debounce`** is the quiet period. An editor's save is three or four
21///   filesystem events, and reloading on each of them reads a file that is
22///   still being written.
23/// - **`ceiling`** bounds it. The quiet period restarts on every event,
24///   which is the point — but under a sustained storm of writes it would
25///   restart forever and the reload would starve. Four times the debounce
26///   unless it is set.
27/// - **`atomic_save_grace`** is the pause after the window closes. An
28///   atomic save writes a temporary file and renames it into place, and the
29///   rename can be observed a hair before the new inode is visible.
30///   [`set_atomic_save_grace`](crate::watch::set_atomic_save_grace)'s
31///   process-wide value unless it is set here.
32///
33/// A bare [`Duration`] converts, so the one-argument form still reads the
34/// way it always did:
35///
36/// ```no_run
37/// # #[cfg(all(feature = "watch", feature = "json"))] {
38/// # use core::time::Duration;
39/// # use serde::Deserialize;
40/// # #[dynamic_config::dynamic_config]
41/// # #[derive(Debug, Deserialize)]
42/// # struct Config { host: String }
43/// use dynamic_config::WatchOptions;
44///
45/// // The common case, unchanged.
46/// let handle = Config::builder("svc").watch(Duration::from_millis(100))?;
47///
48/// // And the same watch, with the numbers under it named.
49/// let handle = Config::builder("svc").watch(
50///     WatchOptions::new(Duration::from_millis(100))
51///         .ceiling(Duration::from_secs(2))
52///         .atomic_save_grace(Duration::from_millis(50)),
53/// )?;
54/// # }
55/// # Ok::<(), std::io::Error>(())
56/// ```
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct WatchOptions {
59    debounce: Duration,
60    ceiling: Option<Duration>,
61    grace: Option<Duration>,
62}
63
64impl WatchOptions {
65    /// The quiet period, with the other two left at their defaults.
66    #[must_use]
67    pub const fn new(debounce: Duration) -> Self {
68        Self {
69            debounce,
70            ceiling: None,
71            grace: None,
72        }
73    }
74
75    /// The longest the quiet period may keep restarting before the reload
76    /// happens regardless.
77    ///
78    /// Four times the debounce unless this is called. Raise it where writes
79    /// arrive in long bursts and reading mid-burst is worse than waiting;
80    /// lower it where a reload must not be starved.
81    #[must_use]
82    pub const fn ceiling(mut self, ceiling: Duration) -> Self {
83        self.ceiling = Some(ceiling);
84        self
85    }
86
87    /// The pause between the window closing and the files being read.
88    ///
89    /// The process-wide value unless this is called — which is the right
90    /// default, because the pause compensates for the *filesystem*. Set it
91    /// per watch where one configuration lives on a local disk and another
92    /// on a mount whose renames take longer to become visible.
93    #[must_use]
94    pub const fn atomic_save_grace(mut self, grace: Duration) -> Self {
95        self.grace = Some(grace);
96        self
97    }
98
99    /// The quiet period.
100    #[must_use]
101    pub const fn debounce_of(&self) -> Duration {
102        self.debounce
103    }
104
105    /// The ceiling in force, defaulted if it was never set.
106    #[must_use]
107    pub(crate) fn ceiling_of(&self) -> Duration {
108        self.ceiling
109            .unwrap_or_else(|| self.debounce.saturating_mul(4))
110    }
111
112    /// The grace in force, falling back to the process-wide value.
113    #[must_use]
114    pub(crate) fn grace_of(&self) -> Duration {
115        self.grace
116            .unwrap_or_else(super::debounce::atomic_save_grace)
117    }
118}
119
120impl From<Duration> for WatchOptions {
121    fn from(debounce: Duration) -> Self {
122        Self::new(debounce)
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn a_bare_duration_is_the_one_argument_form() {
132        let options: WatchOptions = Duration::from_millis(100).into();
133
134        assert_eq!(options.debounce_of(), Duration::from_millis(100));
135    }
136
137    /// The number that used to be written into the loop as `debounce * 4`.
138    #[test]
139    fn the_ceiling_defaults_to_four_debounces() {
140        let options = WatchOptions::new(Duration::from_millis(100));
141
142        assert_eq!(options.ceiling_of(), Duration::from_millis(400));
143    }
144
145    #[test]
146    fn a_named_ceiling_wins() {
147        let options = WatchOptions::new(Duration::from_millis(100)).ceiling(Duration::from_secs(2));
148
149        assert_eq!(options.ceiling_of(), Duration::from_secs(2));
150    }
151
152    /// The grace falls back to the process-wide value, because it
153    /// compensates for the filesystem — which every watcher in the process
154    /// shares. Naming it per watch is for the program watching a local disk
155    /// and a network mount at once.
156    #[test]
157    fn the_grace_falls_back_to_the_process_wide_value() {
158        let options = WatchOptions::new(Duration::from_millis(100));
159
160        assert_eq!(
161            options.grace_of(),
162            super::super::debounce::atomic_save_grace()
163        );
164
165        let named = options.atomic_save_grace(Duration::from_millis(70));
166
167        assert_eq!(named.grace_of(), Duration::from_millis(70));
168    }
169
170    /// A ceiling below the debounce is not corrected: it means "reload as
171    /// soon as the ceiling is up, whatever the quiet period thinks", which
172    /// is a coherent thing to ask for and not this type's business to
173    /// second-guess.
174    #[test]
175    fn a_ceiling_under_the_debounce_is_taken_at_its_word() {
176        let options = WatchOptions::new(Duration::from_secs(1)).ceiling(Duration::from_millis(10));
177
178        assert_eq!(options.ceiling_of(), Duration::from_millis(10));
179    }
180}