Skip to main content

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}