Skip to main content

rightkit_daemon/
ticker.rs

1//! Periodic work and a heartbeat record for a daemon host (issue #376).
2//!
3//! [`spawn_ticker`] runs a closure every interval on its own thread until a [`StopToken`] is
4//! triggered. [`spawn_heartbeat`] is a ticker that writes a small JSON record (`pid`, `updated_ms`
5//! and the caller's `state`) atomically to a path, so a supervisor or a client can read it. Both
6//! end on the first tick after the stop and do no work after it. A write failure is reported to
7//! the caller's `on_error` and the heartbeat keeps going.
8
9use crate::record::{write_atomic, write_atomic_with};
10use crate::stop::StopToken;
11use serde_json::{json, Value};
12use std::io;
13use std::path::PathBuf;
14use std::thread::JoinHandle;
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16
17/// Run `tick` every `interval` until `stop` is triggered. The first tick comes one interval after
18/// the spawn. The thread is named `rk-ticker`.
19pub fn spawn_ticker(
20    interval: Duration,
21    stop: StopToken,
22    mut tick: impl FnMut() + Send + 'static,
23) -> io::Result<JoinHandle<()>> {
24    std::thread::Builder::new()
25        .name("rk-ticker".into())
26        .spawn(move || loop {
27            if stop.wait(interval).is_some() {
28                break;
29            }
30            tick();
31        })
32}
33
34/// The heartbeat record: `pid`, `updated_ms` (milliseconds since the Unix epoch) and `state`.
35pub fn heartbeat_record(state: Value) -> Value {
36    let updated_ms = SystemTime::now()
37        .duration_since(UNIX_EPOCH)
38        .map(|d| d.as_millis() as u64)
39        .unwrap_or(0);
40    json!({"pid": std::process::id(), "updated_ms": updated_ms, "state": state})
41}
42
43/// Write a heartbeat record to `path` now and then every `interval`, until `stop` is triggered.
44/// `state` is called for each write. Writes go through [`write_atomic`], so a reader never sees a
45/// partial record. A failed write is passed to `on_error`; the next interval tries again.
46pub fn spawn_heartbeat(
47    path: PathBuf,
48    interval: Duration,
49    stop: StopToken,
50    state: impl Fn() -> Value + Send + 'static,
51    mut on_error: impl FnMut(&io::Error) + Send + 'static,
52) -> io::Result<JoinHandle<()>> {
53    let write = move |path: &PathBuf, state: &dyn Fn() -> Value| {
54        let body = serde_json::to_vec(&heartbeat_record(state()))
55            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
56        write_atomic(path, &body)
57    };
58    std::thread::Builder::new()
59        .name("rk-heartbeat".into())
60        .spawn(move || loop {
61            if let Err(e) = write(&path, &state) {
62                on_error(&e);
63            }
64            if stop.wait(interval).is_some() {
65                break;
66            }
67        })
68}
69
70/// How [`spawn_heartbeat_with`] paces its writes.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct HeartbeatOptions {
73    /// How often an unchanged state is rewritten (the liveness beat): without `fsync`.
74    pub interval: Duration,
75    /// How often the state is looked at to see whether it changed. A change is written at once
76    /// (durably) at the next look, so this bounds how late a change reaches the record. Clamped to
77    /// `1 ms..=interval`.
78    pub poll: Duration,
79}
80
81impl Default for HeartbeatOptions {
82    /// A 5 s liveness beat and a 1 s change poll, the cadence of Pulse's resident engine.
83    fn default() -> Self {
84        Self {
85            interval: Duration::from_secs(5),
86            poll: Duration::from_secs(1),
87        }
88    }
89}
90
91/// [`spawn_heartbeat`] that does not wear the disk (rightkit#1035). `state` is read every
92/// [`poll`](HeartbeatOptions::poll). When it differs from the state last written, the record is
93/// written durably at once ([`write_atomic`]: a reader and a crash both see the change). When it is
94/// unchanged the record is only rewritten, without `fsync`
95/// ([`write_atomic_with`] with `durable` false), once per [`interval`](HeartbeatOptions::interval),
96/// so `updated_ms` still advances and a reader can tell a live host from a dead one. The first
97/// write is durable and immediate. A failed write is passed to `on_error` and the next look tries
98/// again (a failed change is still a change, so it is retried durably).
99///
100/// `state` must be cheap: it runs once per poll. The thread is named `rk-heartbeat` and ends on the
101/// first look after `stop`.
102pub fn spawn_heartbeat_with(
103    path: PathBuf,
104    options: HeartbeatOptions,
105    stop: StopToken,
106    state: impl Fn() -> Value + Send + 'static,
107    mut on_error: impl FnMut(&io::Error) + Send + 'static,
108) -> io::Result<JoinHandle<()>> {
109    let poll = options
110        .poll
111        .max(Duration::from_millis(1))
112        .min(options.interval.max(Duration::from_millis(1)));
113    let interval = options.interval;
114    std::thread::Builder::new()
115        .name("rk-heartbeat".into())
116        .spawn(move || {
117            let mut last: Option<(Value, std::time::Instant)> = None;
118            loop {
119                let now_state = state();
120                let changed = last.as_ref().is_none_or(|(v, _)| *v != now_state);
121                let due = last.as_ref().is_none_or(|(_, at)| at.elapsed() >= interval);
122                if changed || due {
123                    let written = serde_json::to_vec(&heartbeat_record(now_state.clone()))
124                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
125                        .and_then(|body| write_atomic_with(&path, &body, changed));
126                    match written {
127                        Ok(()) => last = Some((now_state, std::time::Instant::now())),
128                        Err(e) => on_error(&e),
129                    }
130                }
131                if stop.wait(poll).is_some() {
132                    break;
133                }
134            }
135        })
136}
137
138/// Beat the `heartbeat_ms` and `state` fields of this process's own `service.json`
139/// ([`ServiceRecord`](crate::ServiceRecord), rightkit#404) with the same two rules as
140/// [`spawn_heartbeat_with`]: a change of `state` is written durably at once, an unchanged state is
141/// rewritten without `fsync` once per interval. The record must exist and be this process's (its
142/// `pid` equals [`std::process::id`]); a record that is missing, unreadable or another process's
143/// is reported to `on_error` and never overwritten, so a host that lost its election does not
144/// clobber the winner. Only `heartbeat_ms` and `state` change.
145pub fn spawn_record_heartbeat(
146    suite: crate::Suite,
147    service: String,
148    options: HeartbeatOptions,
149    stop: StopToken,
150    state: impl Fn() -> String + Send + 'static,
151    mut on_error: impl FnMut(&io::Error) + Send + 'static,
152) -> io::Result<JoinHandle<()>> {
153    let path = crate::record_path(&suite, &service);
154    let poll = options
155        .poll
156        .max(Duration::from_millis(1))
157        .min(options.interval.max(Duration::from_millis(1)));
158    let interval = options.interval;
159    std::thread::Builder::new()
160        .name("rk-record-heartbeat".into())
161        .spawn(move || {
162            let mut last: Option<(String, std::time::Instant)> = None;
163            loop {
164                let now_state = state();
165                let changed = last.as_ref().is_none_or(|(v, _)| *v != now_state);
166                let due = last.as_ref().is_none_or(|(_, at)| at.elapsed() >= interval);
167                if changed || due {
168                    let written = (|| -> io::Result<()> {
169                        let mut record =
170                            crate::try_read_record(&suite, &service)?.ok_or_else(|| {
171                                io::Error::new(io::ErrorKind::NotFound, "no service record")
172                            })?;
173                        if record.pid != std::process::id() {
174                            return Err(io::Error::new(
175                                io::ErrorKind::PermissionDenied,
176                                "the service record belongs to another process",
177                            ));
178                        }
179                        record.heartbeat_ms = SystemTime::now()
180                            .duration_since(UNIX_EPOCH)
181                            .map(|d| d.as_millis() as u64)
182                            .unwrap_or(0);
183                        record.state = now_state.clone();
184                        let body = serde_json::to_vec_pretty(&record)
185                            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
186                        write_atomic_with(&path, &body, changed)
187                    })();
188                    match written {
189                        Ok(()) => last = Some((now_state, std::time::Instant::now())),
190                        Err(e) => on_error(&e),
191                    }
192                }
193                if stop.wait(poll).is_some() {
194                    break;
195                }
196            }
197        })
198}