Skip to main content

kunobi_daemon/
generation.rs

1//! Generation selection and session retirement, independent of application messages.
2use std::{
3    io,
4    path::Path,
5    sync::{
6        Arc, Mutex,
7        atomic::{AtomicU64, Ordering},
8    },
9    time::Duration,
10};
11use tokio::{sync::watch, time::Instant};
12
13/// Allocate above every persisted generation while holding the upgrade lock.
14/// The counter uses a decimal integer, compatible with an integer JSON record.
15pub fn allocate(
16    _lock: &crate::ProcessLock,
17    directory: &Path,
18    counter: &Path,
19    selected: u64,
20) -> io::Result<u64> {
21    let prior = match std::fs::read_to_string(counter) {
22        Ok(text) => text.trim().parse::<u64>().map_err(io::Error::other)?,
23        Err(e) if e.kind() == io::ErrorKind::NotFound => 0,
24        Err(e) => return Err(e),
25    };
26    let mut maximum = prior.max(selected);
27    for entry in std::fs::read_dir(directory)? {
28        if let Some(value) = entry?
29            .path()
30            .file_stem()
31            .and_then(|name| name.to_str())
32            .and_then(|name| name.parse::<u64>().ok())
33        {
34            maximum = maximum.max(value);
35        }
36    }
37    let next = maximum
38        .checked_add(1)
39        .ok_or_else(|| io::Error::other("generation exhausted"))?;
40    crate::publish_record(counter, next.to_string().as_bytes())?;
41    Ok(next)
42}
43
44#[derive(Default)]
45struct Sessions {
46    active: usize,
47    legacy: usize,
48    retired: bool,
49    superseded_since: Option<Instant>,
50}
51
52/// A process generation keeps admitted sessions alive until their leases drop.
53pub struct Generation {
54    own: u64,
55    selected: AtomicU64,
56    sessions: Mutex<Sessions>,
57    changes: watch::Sender<()>,
58    started: Instant,
59    staged: bool,
60    drain_limit: Option<Duration>,
61}
62
63/// Why [`Generation::run_until_retired`] returned.
64#[derive(Clone, Copy, Debug, PartialEq, Eq)]
65#[non_exhaustive]
66pub enum Retirement {
67    /// No session lease was held.
68    Idle,
69    /// The drain limit expired while leases were still held. Admission is
70    /// closed; the application decides how to end the remaining sessions.
71    DrainExpired {
72        /// Session leases still held at retirement.
73        active: usize,
74        /// Of those, sessions using a legacy protocol.
75        legacy: usize,
76    },
77}
78
79/// Local session state. Counts describe leases, not application operations.
80#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81#[non_exhaustive]
82pub struct Snapshot {
83    /// This process's epoch.
84    pub own: u64,
85    /// Monotonic selected epoch.
86    pub selected: u64,
87    /// Admitted sessions still held.
88    pub active: usize,
89    /// Admitted sessions using a legacy protocol.
90    pub legacy: usize,
91    /// Admission closed permanently after retirement.
92    pub retired: bool,
93    /// How long a newer generation has been selected, if one has.
94    pub superseded_for: Option<Duration>,
95}
96impl Generation {
97    /// Refresh application discovery until this process can retire. This loop
98    /// owns session wakeups and retirement; the callback reads application
99    /// selection and may invoke the shared replacement transaction.
100    ///
101    /// With a drain limit, the loop also wakes when the limit passes, and checks
102    /// it before and after every refresh, so no refresh starts once it has
103    /// passed. A `refresh` callback that never completes still delays retirement.
104    #[expect(
105        clippy::disallowed_methods,
106        reason = "Discovery has no change notification; session changes wake the same select immediately."
107    )]
108    pub async fn run_until_retired<F, Fut>(
109        &self,
110        interval: Duration,
111        staging_lifetime: Duration,
112        mut refresh: F,
113    ) -> Retirement
114    where
115        F: FnMut() -> Fut,
116        Fut: std::future::Future<Output = ()>,
117    {
118        let mut changes = self.subscribe();
119        loop {
120            changes.borrow_and_update();
121            if let Some(retirement) = self.try_retire(staging_lifetime, false, true) {
122                return retirement;
123            }
124            refresh().await;
125            if let Some(retirement) = self.retire(staging_lifetime) {
126                return retirement;
127            }
128            // Wake for the drain limit too, not only the refresh interval.
129            let wait = self
130                .drain_remaining()
131                .map_or(interval, |remaining| remaining.min(interval));
132            tokio::select! {
133                _ = tokio::time::sleep(wait) => {},
134                _ = changes.changed() => {},
135            }
136        }
137    }
138    /// Initialize from the persisted selection and the process's startup record.
139    pub fn new(own: u64, selected: u64, staged: bool) -> Self {
140        let started = Instant::now();
141        Self {
142            own,
143            selected: AtomicU64::new(selected),
144            sessions: Mutex::new(Sessions {
145                superseded_since: (selected > own).then_some(started),
146                ..Sessions::default()
147            }),
148            changes: watch::channel(()).0,
149            started,
150            staged,
151            drain_limit: None,
152        }
153    }
154    /// Bound how long a superseded generation waits for its session leases.
155    ///
156    /// Without a limit a held lease keeps a superseded generation alive
157    /// indefinitely. With one, [`Self::retire`] and [`Self::run_until_retired`]
158    /// retire it once the limit has passed since a newer generation was first
159    /// selected, whatever leases remain.
160    #[must_use]
161    pub fn with_drain_limit(mut self, limit: Duration) -> Self {
162        self.drain_limit = Some(limit);
163        self
164    }
165    /// Return the latest observed selected epoch.
166    pub fn selected(&self) -> u64 {
167        self.selected.load(Ordering::Acquire)
168    }
169    /// Observe a committed selection. Binary rollback still requires a larger epoch.
170    pub fn select(&self, selected: u64) -> io::Result<()> {
171        // Under the session lock, so retirement decides against a selection
172        // and drain clock that cannot change underneath it.
173        let mut state = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
174        let previous = self
175            .selected
176            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |old| {
177                (selected >= old).then_some(selected)
178            })
179            .map_err(|_| io::Error::other("generation selection moved backwards"))?;
180        if previous != selected {
181            if selected > self.own {
182                state.superseded_since.get_or_insert_with(Instant::now);
183            }
184            drop(state);
185            self.changes.send_replace(());
186        }
187        Ok(())
188    }
189    /// Admit a session unless retirement closed admission.
190    pub fn admit(self: &Arc<Self>) -> Option<SessionLease> {
191        let mut state = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
192        if state.retired {
193            return None;
194        }
195        state.active += 1;
196        Some(SessionLease {
197            generation: Arc::clone(self),
198            legacy: false,
199        })
200    }
201    /// Retire only an idle superseded generation or expired unselected candidate.
202    ///
203    /// Ignores the drain limit; see [`Self::retire`].
204    pub fn retire_if_idle(&self, staging_lifetime: Duration) -> bool {
205        self.try_retire(staging_lifetime, true, false).is_some()
206    }
207    /// Retire an idle superseded generation or expired unselected candidate,
208    /// or a superseded generation whose drain limit has passed.
209    pub fn retire(&self, staging_lifetime: Duration) -> Option<Retirement> {
210        self.try_retire(staging_lifetime, true, true)
211    }
212    /// `idle` retires an eligible generation without leases; `drain` retires a
213    /// superseded one whose drain limit has passed.
214    fn try_retire(
215        &self,
216        staging_lifetime: Duration,
217        idle: bool,
218        drain: bool,
219    ) -> Option<Retirement> {
220        let mut state = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
221        let selected = self.selected();
222        if selected <= self.own
223            && !(self.staged && selected < self.own && self.started.elapsed() > staging_lifetime)
224        {
225            return None;
226        }
227        // Set only once a newer generation is selected; selection never moves back.
228        let expired = drain
229            && self
230                .drain_limit
231                .zip(state.superseded_since)
232                .is_some_and(|(limit, since)| since.elapsed() >= limit);
233        let idle = idle && state.active == 0;
234        if !(expired || idle) {
235            return None;
236        }
237        state.retired = true;
238        Some(if state.active == 0 {
239            Retirement::Idle
240        } else {
241            Retirement::DrainExpired {
242                active: state.active,
243                legacy: state.legacy,
244            }
245        })
246    }
247    /// Time left before the drain limit retires this generation, once superseded.
248    fn drain_remaining(&self) -> Option<Duration> {
249        let state = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
250        self.drain_limit
251            .zip(state.superseded_since)
252            .map(|(limit, since)| limit.saturating_sub(since.elapsed()))
253    }
254    /// Snapshot without waiting for any transport I/O.
255    pub fn snapshot(&self) -> Snapshot {
256        let state = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
257        Snapshot {
258            own: self.own,
259            selected: self.selected(),
260            active: state.active,
261            legacy: state.legacy,
262            retired: state.retired,
263            superseded_for: state.superseded_since.map(|since| since.elapsed()),
264        }
265    }
266    /// Subscribe before reading state so a concurrent release is not missed.
267    ///
268    /// Notifies when the selected generation advances and when a session lease
269    /// is released: the transitions that can make retirement possible.
270    /// Admission and [`SessionLease::mark_legacy`] do not notify, so
271    /// [`Self::run_until_retired`] does not refresh once per new session.
272    pub fn subscribe(&self) -> watch::Receiver<()> {
273        self.changes.subscribe()
274    }
275}
276
277/// Keeps a generation alive while its transport or application session is owned.
278pub struct SessionLease {
279    generation: Arc<Generation>,
280    legacy: bool,
281}
282impl SessionLease {
283    /// Mark the session once when a legacy protocol has been authenticated.
284    pub fn mark_legacy(&mut self) {
285        if !self.legacy {
286            self.legacy = true;
287            self.generation
288                .sessions
289                .lock()
290                .unwrap_or_else(|e| e.into_inner())
291                .legacy += 1;
292        }
293    }
294}
295impl Drop for SessionLease {
296    fn drop(&mut self) {
297        let mut state = self
298            .generation
299            .sessions
300            .lock()
301            .unwrap_or_else(|e| e.into_inner());
302        state.active -= 1;
303        if self.legacy {
304            state.legacy -= 1;
305        }
306        self.generation.changes.send_replace(());
307    }
308}