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}