didbot_serve/tree_reload.rs
1//! The timer that re-reads `[tree]`'s bounds from the configuration file.
2//!
3//! The bounds a create is judged by are the ones an operator most needs to
4//! change while the server is busy: a cap that is refusing work is the worst
5//! moment to take the server down. So they are held in a
6//! [`didbot_pds::LiveTreeLimits`] the registry reads per create, and this is
7//! the clock that replaces what it holds.
8//!
9//! A pass reads and parses the file whole and installs what it found only if
10//! every bound is in range. Anything else — the file gone, the TOML bad, a
11//! bound out of range — installs nothing and leaves the bounds that were
12//! already live in place. The built-in defaults are never reinstated by a
13//! failure: a configuration file that disappears must not widen or narrow
14//! what the operator last asked for.
15
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::time::Duration;
19
20use didbot_pds::{LiveTreeLimits, TreeLimits};
21use tokio::sync::Notify;
22use tokio::task::JoinHandle;
23use tokio::time::MissedTickBehavior;
24use tracing::{info, warn};
25
26/// How often the bounds are re-read, absent `[intervals] tree_reload_secs`.
27///
28/// Thirty seconds: an operator raising a cap that is refusing work waits
29/// about as long as a page reload, and a pass costs one read of one local
30/// file.
31pub const DEFAULT_TREE_RELOAD_INTERVAL: Duration = Duration::from_secs(30);
32
33/// Why a pass installed nothing. The live bounds keep their values.
34#[derive(Debug, thiserror::Error)]
35pub enum TreeReloadError {
36 /// The file could not be read, or could not be parsed.
37 #[error(transparent)]
38 Config(#[from] didbot_config::ConfigError),
39 /// A bound was written outside the range it is documented with.
40 #[error(transparent)]
41 Range(#[from] didbot_pds::TreeLimitError),
42}
43
44/// The `[tree]` bounds `config` asks for, each falling back to
45/// [`TreeLimits`]'s own default where the key is absent.
46///
47/// Shared with the startup path so that a reload resolves a file exactly the
48/// way the process that started against it did.
49#[must_use]
50pub fn tree_limits_of(config: &didbot_config::Config) -> TreeLimits {
51 let defaults = TreeLimits::default();
52 let section = config.tree.as_ref();
53 TreeLimits {
54 max_depth: section
55 .and_then(|tree| tree.max_depth)
56 .unwrap_or(defaults.max_depth),
57 max_children: section
58 .and_then(|tree| tree.max_children)
59 .unwrap_or(defaults.max_children),
60 creates_per_hour: section
61 .and_then(|tree| tree.creates_per_hour)
62 .unwrap_or(defaults.creates_per_hour),
63 }
64}
65
66/// One pass: read `path`, and install its bounds into `live` if they are
67/// whole and in range.
68///
69/// Public and separate from the timer for the reason
70/// [`crate::blob_collect::collect_once`] is: a caller can run exactly one
71/// pass without waiting on a clock. Answers the bounds now live on success,
72/// whether or not they changed.
73pub fn reload_once(path: &Path, live: &LiveTreeLimits) -> Result<TreeLimits, TreeReloadError> {
74 let config = didbot_config::Config::load(path)?;
75 let limits = tree_limits_of(&config);
76 limits.check()?;
77 let before = live.get();
78 if before != limits {
79 // Installed as one value, so a create judged during this swap sees
80 // either every old bound or every new one.
81 live.set(limits);
82 info!(
83 path = %path.display(),
84 max_depth = limits.max_depth,
85 max_children = limits.max_children,
86 creates_per_hour = limits.creates_per_hour,
87 "[tree] changed; every later create is judged by these bounds"
88 );
89 }
90 Ok(limits)
91}
92
93/// A running reloader, stopped by [`TreeReloader::stop`] or by dropping it.
94pub struct TreeReloader {
95 handle: JoinHandle<()>,
96 stop: Arc<Notify>,
97}
98
99impl TreeReloader {
100 /// Starts re-reading `path` into `live` every `interval`.
101 ///
102 /// The first tick fires after one interval, not immediately: the bounds
103 /// were resolved from this same file moments ago, at startup, so a pass
104 /// at zero would only read it twice.
105 pub fn spawn(path: PathBuf, live: Arc<LiveTreeLimits>, interval: Duration) -> Self {
106 let stop = Arc::new(Notify::new());
107 let notified = stop.clone();
108 let handle = tokio::spawn(async move {
109 let mut ticker = tokio::time::interval(interval);
110 ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
111 ticker.tick().await;
112 // What the last pass complained about, so a file left broken
113 // warns once rather than once per tick.
114 let mut complained: Option<String> = None;
115 loop {
116 tokio::select! {
117 biased;
118 () = notified.notified() => break,
119 _ = ticker.tick() => {}
120 }
121 let file = path.clone();
122 let live = live.clone();
123 let outcome = tokio::task::spawn_blocking(move || reload_once(&file, &live)).await;
124 match outcome {
125 Ok(Ok(_)) => complained = None,
126 Ok(Err(err)) => {
127 let said = err.to_string();
128 if complained.as_deref() != Some(said.as_str()) {
129 warn!(
130 path = %path.display(),
131 error = %said,
132 "could not re-read [tree]; the bounds already live still stand"
133 );
134 complained = Some(said);
135 }
136 }
137 Err(error) => warn!(
138 %error,
139 "the [tree] reload did not finish; the next tick tries again"
140 ),
141 }
142 }
143 });
144 Self { handle, stop }
145 }
146
147 /// Stops the reloader and waits for the task to finish.
148 pub async fn stop(mut self) {
149 self.stop.notify_one();
150 let _ = (&mut self.handle).await;
151 }
152}
153
154impl Drop for TreeReloader {
155 fn drop(&mut self) {
156 self.handle.abort();
157 }
158}