Skip to main content

gunnar_core/
activity.rs

1//! **When a principal last signed in, and when it last changed something** —
2//! the two answers an administrator reads in the console's user list
3//! (owner, 2026-10-21).
4//!
5//! # Two stamps, kept apart
6//!
7//! * **signed in** — a console session was issued to the principal: a passkey
8//!   login (`Access.WebLogin`, the SSO second factor that ends in one) or an
9//!   SSO login that enrolled a session key.
10//! * **operated** — an authenticated call that changed something: a push that
11//!   moved a reference, or an allowed control-plane call that is not a read
12//!   (a repository made or removed, a setting, a key added, …). A read is not
13//!   an operation.
14//!
15//! # Where they live: a table of the control plane's redb, written in batches
16//!
17//! Not on the principal row: the identity table is ONE compare-and-swap
18//! document (`refs/gunnar/auth`, see `AuthTable::touch_key`), and a write per
19//! authenticated request would put every request on the box in contention for
20//! one pointer. So the stamps are **kept in memory, moved at most once per
21//! [`GRANULARITY`] per principal and kind**, and the rows that moved are
22//! written by [`PrincipalActivity::flush`] — a background job
23//! (a jera job of the server, `gunnar_server::stores::start_activity_flush`), never the request's — in **one redb
24//! write transaction per flush that changed something**, into the control
25//! database's own `principal_activity` table ([`crate::ControlDb`]). That
26//! table is per box: never framed into the transaction log, never in a
27//! snapshot or an export.
28//!
29//! **The twin holds them.** On a box that originates its transaction log, a
30//! flush that moved something is also ONE [`crate::txlog::Kind::Activity`]
31//! frame — the batch, appended before the table is written, under the log's
32//! mutation guard — and the twin applies it into its own table
33//! ([`PrincipalActivity::absorb`]). A snapshot carries the whole table as one
34//! item, so a twin that joins later has every stamp; a twin promoted to main
35//! keeps them. A follower frames nothing of its own.
36//!
37//! **Allowed to lose updates.** A crash loses at most the stamps since the last
38//! flush ([`FLUSH_EVERY`]); a box restored from its twin starts with no stamps.
39//! Either way the answer is "a little older than the truth" or "never", never
40//! wrong about who. While the store is off the stripe (a data-set growth) a
41//! flush is refused and the rows wait in memory for the next one.
42//!
43//! # The JSON file it replaced
44//!
45//! The first version wrote `principal-activity.json` beside the control
46//! store ([`ACTIVITY_FILE`]). A box that has one reads it ONCE into the table
47//! (the marker [`IMPORTED_MARK`] says it was) and leaves the file where it is.
48
49use std::collections::{BTreeMap, BTreeSet};
50use std::path::PathBuf;
51use std::sync::{Arc, Mutex};
52use std::time::Duration;
53
54use serde::{Deserialize, Serialize};
55
56use crate::control::ControlDb;
57use crate::txlog::{Kind, Log, Meta};
58
59/// The JSON file the stamps were kept in before they moved into redb; read
60/// once into the table and never written again.
61pub const ACTIVITY_FILE: &str = "principal-activity.json";
62
63/// The marker (in the control database's per-box marks) that says
64/// [`ACTIVITY_FILE`] has been read into the table.
65pub const IMPORTED_MARK: &str = "principal-activity-json-imported";
66
67/// How far a stamp must have moved before it is moved again: one minute, the
68/// same grain as a token's `last_used` (`crate::auth::LAST_USED_GRANULARITY`).
69/// The console reads it as "x minutes ago".
70pub const GRANULARITY: i64 = 60_000;
71
72/// How often the background thread writes what moved.
73pub const FLUSH_EVERY: Duration = Duration::from_secs(30);
74
75/// One principal's two stamps, Unix milliseconds. `None`: never (or not since
76/// this box started keeping them).
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
78pub struct Seen {
79    /// The last console session issued.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub signed_in: Option<i64>,
82    /// The last authenticated call that changed something.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub operated: Option<i64>,
85}
86
87impl Seen {
88    /// The later of each stamp.
89    fn merge(&mut self, other: Seen) {
90        self.signed_in = self.signed_in.max(other.signed_in);
91        self.operated = self.operated.max(other.operated);
92    }
93}
94
95#[derive(Default)]
96struct Inner {
97    seen: BTreeMap<String, Seen>,
98    /// The principals whose stamps moved since the last flush.
99    dirty: BTreeSet<String>,
100}
101
102/// The stamps, in memory, written to the control database in batches. See the
103/// module.
104pub struct PrincipalActivity {
105    db: Option<Arc<ControlDb>>,
106    /// The transaction log a flush frames its batch into (when it originates).
107    log: Option<Arc<Log>>,
108    inner: Mutex<Inner>,
109}
110
111/// **A batch of stamps as a frame's or a snapshot item's blob**: JSON,
112/// principal id → stamps.
113pub fn encode_batch(rows: &BTreeMap<String, Seen>) -> crate::error::Result<Vec<u8>> {
114    serde_json::to_vec(rows).map_err(|err| crate::error::Error::ControlStore(format!("encoding last-seen stamps: {err}")))
115}
116
117/// [`encode_batch`] over the table's raw rows (each already one encoded [`Seen`]).
118pub fn encode_rows(rows: &[(String, Vec<u8>)]) -> crate::error::Result<Vec<u8>> {
119    let mut map = BTreeMap::new();
120    for (id, raw) in rows {
121        if let Ok(seen) = serde_json::from_slice::<Seen>(raw) {
122            map.insert(id.clone(), seen);
123        }
124    }
125    encode_batch(&map)
126}
127
128/// A frame's or a snapshot item's blob back to its stamps.
129pub fn decode_batch(blob: &[u8]) -> crate::error::Result<BTreeMap<String, Seen>> {
130    serde_json::from_slice(blob).map_err(|err| crate::error::Error::ControlStore(format!("a last-seen batch does not parse: {err}")))
131}
132
133fn row_of(id: &str, seen: &Seen) -> Option<(String, Vec<u8>)> {
134    serde_json::to_vec(seen).ok().map(|v| (id.to_owned(), v))
135}
136
137impl std::fmt::Debug for PrincipalActivity {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.debug_struct("PrincipalActivity").field("db", &self.db.is_some()).finish_non_exhaustive()
140    }
141}
142
143/// Move `slot` to `now` when it is empty or at least [`GRANULARITY`] older.
144/// Never backwards. `true` when it moved.
145fn advance(slot: &mut Option<i64>, now: i64) -> bool {
146    match *slot {
147        Some(last) if now.saturating_sub(last) < GRANULARITY => false,
148        _ => {
149            *slot = Some(now);
150            true
151        }
152    }
153}
154
155fn warn(what: &str, err: &dyn std::fmt::Display) {
156    tracing::warn!(target: "gunnar::activity", error = %err, "{what}");
157}
158
159impl PrincipalActivity {
160    /// Stamps kept in memory only (a test, a tool).
161    pub fn in_memory() -> PrincipalActivity {
162        PrincipalActivity { db: None, log: None, inner: Mutex::new(Inner::default()) }
163    }
164
165    /// The stamps kept in `db`'s `principal_activity` table, read back — and,
166    /// the first time, the old [`ACTIVITY_FILE`] at `legacy_json` read into
167    /// it (the file is left). A table that cannot be read, or a file that does
168    /// not parse, is said at `warn!` and started over: this is bookkeeping,
169    /// and a box must never refuse to start over it.
170    pub fn open(db: Arc<ControlDb>, legacy_json: Option<PathBuf>, log: Option<Arc<Log>>) -> PrincipalActivity {
171        let mut seen = BTreeMap::new();
172        match db.principal_activity() {
173            Ok(rows) => {
174                for (id, raw) in rows {
175                    match serde_json::from_slice::<Seen>(&raw) {
176                        Ok(s) => {
177                            seen.insert(id, s);
178                        }
179                        Err(err) => warn("a last-seen row does not parse; it is started over", &err),
180                    }
181                }
182            }
183            Err(err) => warn("the last-seen table could not be read; starting it over", &err),
184        }
185        let activity = PrincipalActivity { db: Some(Arc::clone(&db)), log, inner: Mutex::new(Inner { seen, dirty: BTreeSet::new() }) };
186        if let Some(path) = legacy_json {
187            activity.import_once(&db, path);
188        }
189        activity
190    }
191
192    /// Read the old JSON file into the table, once: its rows and the marker go
193    /// in one transaction, and the file stays where it is.
194    fn import_once(&self, db: &ControlDb, path: PathBuf) {
195        match db.mark(IMPORTED_MARK) {
196            Ok(Some(_)) => return,
197            Ok(None) => {}
198            Err(err) => return warn("the last-seen import marker could not be read", &err),
199        }
200        let old: BTreeMap<String, Seen> = match std::fs::read(&path) {
201            Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|err| {
202                warn("the old principal-activity.json does not parse; it is not imported", &err);
203                BTreeMap::new()
204            }),
205            // No old file: nothing to import, and nothing marked.
206            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return,
207            Err(err) => {
208                warn("the old principal-activity.json could not be read; it is not imported", &err);
209                BTreeMap::new()
210            }
211        };
212        let rows: Vec<(String, Vec<u8>)> = {
213            let mut inner = self.lock();
214            for (id, s) in old {
215                inner.seen.entry(id.clone()).or_default().merge(s);
216                // The twin gets them with the next flush's frame.
217                inner.dirty.insert(id);
218            }
219            let inner = &*inner;
220            inner.seen.iter().filter_map(|(id, s)| row_of(id, s)).collect()
221        };
222        if let Err(err) = db.put_principal_activity(&rows, Some(IMPORTED_MARK)) {
223            warn("the old principal-activity.json could not be written into the table; tried again next start", &err);
224        }
225    }
226
227    fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
228        self.inner.lock().unwrap_or_else(|p| p.into_inner())
229    }
230
231    fn stamp(&self, principal_id: &str, now: i64, pick: fn(&mut Seen) -> &mut Option<i64>) {
232        if principal_id.is_empty() {
233            return;
234        }
235        let mut inner = self.lock();
236        // The common case — a stamp younger than the grain — allocates nothing.
237        if let Some(seen) = inner.seen.get_mut(principal_id) {
238            if advance(pick(seen), now) && !inner.dirty.contains(principal_id) {
239                inner.dirty.insert(principal_id.to_owned());
240            }
241            return;
242        }
243        let mut seen = Seen::default();
244        advance(pick(&mut seen), now);
245        inner.seen.insert(principal_id.to_owned(), seen);
246        inner.dirty.insert(principal_id.to_owned());
247    }
248
249    /// A console session was issued to `principal_id` at `now`.
250    pub fn signed_in(&self, principal_id: &str, now: i64) {
251        self.stamp(principal_id, now, |s| &mut s.signed_in);
252    }
253
254    /// `principal_id` made a call that changed something at `now`.
255    pub fn operated(&self, principal_id: &str, now: i64) {
256        self.stamp(principal_id, now, |s| &mut s.operated);
257    }
258
259    /// One principal's stamps.
260    pub fn seen(&self, principal_id: &str) -> Seen {
261        self.lock().seen.get(principal_id).copied().unwrap_or_default()
262    }
263
264    /// Every principal's stamps.
265    pub fn all(&self) -> BTreeMap<String, Seen> {
266        self.lock().seen.clone()
267    }
268
269    /// Something was stamped that is not in the table yet.
270    pub fn is_dirty(&self) -> bool {
271        !self.lock().dirty.is_empty()
272    }
273
274    /// Write the rows that moved since the last flush: **one** `Activity`
275    /// frame for all of them (on a box that originates its log), then **one**
276    /// redb write transaction. `Ok(false)` when nothing moved (nothing is
277    /// written or framed) or this record keeps no database. A refused write
278    /// keeps the rows for the next flush.
279    pub fn flush(&self) -> crate::error::Result<bool> {
280        let Some(db) = &self.db else {
281            return Ok(false);
282        };
283        let (names, batch) = {
284            let mut inner = self.lock();
285            if inner.dirty.is_empty() {
286                return Ok(false);
287            }
288            let names = std::mem::take(&mut inner.dirty);
289            let batch: BTreeMap<String, Seen> =
290                names.iter().filter_map(|id| inner.seen.get(id).map(|s| (id.clone(), *s))).collect();
291            (names, batch)
292        };
293        let rows: Vec<(String, Vec<u8>)> = batch.iter().filter_map(|(id, s)| row_of(id, s)).collect();
294        let written = (|| {
295            let framing = self.log.as_ref().filter(|log| !log.is_follower());
296            // The frame and the table under one mutation guard: a snapshot's cut sees both or
297            // neither.
298            let _m = framing.map(|log| log.mutation());
299            if let Some(log) = framing {
300                log.append(Kind::Activity, &Meta::Activity {}, &encode_batch(&batch)?)
301                    .map_err(|err| crate::error::Error::ControlStore(format!("framing last-seen stamps: {err}")))?;
302            }
303            db.put_principal_activity(&rows, None)
304        })();
305        if let Err(err) = written {
306            self.lock().dirty.extend(names);
307            return Err(err);
308        }
309        Ok(true)
310    }
311
312    /// **Take stamps from the primary** (a twin applying an `Activity` frame or a
313    /// snapshot item; a box being restored): each one merged in memory, the later
314    /// of each stamp kept, and the merged rows written to the table in one
315    /// transaction. Framed by nobody — it is already a frame.
316    pub fn absorb(&self, rows: BTreeMap<String, Seen>) -> crate::error::Result<()> {
317        let merged: Vec<(String, Vec<u8>)> = {
318            let mut inner = self.lock();
319            rows.into_iter()
320                .filter_map(|(id, s)| {
321                    let slot = inner.seen.entry(id.clone()).or_default();
322                    slot.merge(s);
323                    row_of(&id, slot)
324                })
325                .collect()
326        };
327        match &self.db {
328            Some(db) if !merged.is_empty() => db.put_principal_activity(&merged, None),
329            _ => Ok(()),
330        }
331    }
332
333    fn flush_logged(&self) {
334        if let Err(err) = self.flush() {
335            warn("the last-seen rows could not be written; they are kept in memory and tried again", &err);
336        }
337    }
338}
339
340impl Drop for PrincipalActivity {
341    fn drop(&mut self) {
342        self.flush_logged();
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    fn db_at(path: &std::path::Path) -> Arc<ControlDb> {
351        crate::control::open_control_with_db(path).expect("control store").2
352    }
353
354    #[test]
355    fn a_stamp_moves_only_past_the_grain_and_never_backwards() {
356        let a = PrincipalActivity::in_memory();
357        a.operated("kim", 1_000_000);
358        assert_eq!(a.seen("kim").operated, Some(1_000_000));
359        a.operated("kim", 1_000_000 + GRANULARITY - 1);
360        assert_eq!(a.seen("kim").operated, Some(1_000_000), "inside the grain");
361        a.operated("kim", 1_000_000 - 5 * GRANULARITY);
362        assert_eq!(a.seen("kim").operated, Some(1_000_000), "an older stamp does not move it back");
363        a.operated("kim", 1_000_000 + GRANULARITY);
364        assert_eq!(a.seen("kim").operated, Some(1_000_000 + GRANULARITY));
365        assert_eq!(a.seen("kim").signed_in, None, "the two stamps are apart");
366        assert_eq!(a.seen("nobody"), Seen::default());
367    }
368
369    #[test]
370    fn a_flush_is_one_commit_for_many_stamps_and_none_when_nothing_moved() {
371        let dir = tempfile::tempdir().expect("tempdir");
372        let path = dir.path().join("control.redb");
373        {
374            let db = db_at(&path);
375            let a = PrincipalActivity::open(Arc::clone(&db), None, None);
376            let before = db.durable_commits();
377            assert!(!a.flush().expect("flush"), "nothing moved, nothing written");
378            a.signed_in("kim", 5_000_000);
379            for i in 0..1_000 {
380                a.operated("kim", 5_000_000 + i);
381            }
382            a.operated("ann", 6_000_000);
383            assert_eq!(db.durable_commits(), before, "a stamp writes nothing");
384            assert!(a.flush().expect("flush"));
385            assert_eq!(db.durable_commits(), before + 1, "one transaction for the whole batch");
386            assert!(!a.flush().expect("flush"), "the second flush has nothing to write");
387            a.operated("kim", 5_000_000 + 10);
388            assert!(!a.is_dirty(), "a stamp inside the grain does not make a write");
389            assert_eq!(db.durable_commits(), before + 1);
390        }
391        let a = PrincipalActivity::open(db_at(&path), None, None);
392        assert_eq!(a.seen("kim"), Seen { signed_in: Some(5_000_000), operated: Some(5_000_000) });
393        assert_eq!(a.seen("ann").operated, Some(6_000_000));
394    }
395
396    #[test]
397    fn the_last_stamps_are_written_when_the_record_is_dropped() {
398        let dir = tempfile::tempdir().expect("tempdir");
399        let path = dir.path().join("control.redb");
400        PrincipalActivity::open(db_at(&path), None, None).signed_in("kim", 7);
401        assert_eq!(PrincipalActivity::open(db_at(&path), None, None).seen("kim").signed_in, Some(7));
402    }
403
404    #[test]
405    fn an_old_json_file_is_imported_once_and_left() {
406        let dir = tempfile::tempdir().expect("tempdir");
407        let path = dir.path().join("control.redb");
408        let json = dir.path().join(ACTIVITY_FILE);
409        std::fs::write(&json, br#"{"kim":{"signed_in":1000,"operated":2000}}"#).expect("write");
410        assert_eq!(PrincipalActivity::open(db_at(&path), Some(json.clone()), None).seen("kim"), Seen { signed_in: Some(1000), operated: Some(2000) });
411        assert!(json.exists(), "the file is left");
412        std::fs::write(&json, br#"{"kim":{"signed_in":9000}}"#).expect("rewrite");
413        assert_eq!(PrincipalActivity::open(db_at(&path), Some(json), None).seen("kim").signed_in, Some(1000), "read once");
414    }
415
416    #[test]
417    fn a_flush_on_an_originating_box_is_one_frame_and_a_follower_frames_nothing() {
418        let dir = tempfile::tempdir().expect("tempdir");
419        let log = Arc::new(Log::open(dir.path().join("txlog")).expect("log"));
420        let a = PrincipalActivity::open(db_at(&dir.path().join("control.redb")), None, Some(Arc::clone(&log)));
421        a.signed_in("kim", 1);
422        a.operated("ann", 2);
423        assert!(a.flush().expect("flush"));
424        assert_eq!(log.head_seq(), 1, "one frame for the batch");
425        let e = log.read(1).expect("the frame");
426        assert_eq!(e.kind(), Some(Kind::Activity));
427        let batch = decode_batch(&e.blob).expect("decodes");
428        assert_eq!(batch.get("kim").and_then(|s| s.signed_in), Some(1));
429        assert_eq!(batch.get("ann").and_then(|s| s.operated), Some(2));
430        log.set_follower(true);
431        a.operated("kim", 10 * GRANULARITY);
432        assert!(a.flush().expect("a follower still writes its table"));
433        assert_eq!(log.head_seq(), 1, "a follower frames nothing");
434    }
435
436    #[test]
437    fn absorbed_stamps_are_merged_and_written_without_a_frame() {
438        let dir = tempfile::tempdir().expect("tempdir");
439        let path = dir.path().join("control.redb");
440        {
441            let a = PrincipalActivity::open(db_at(&path), None, None);
442            a.operated("kim", 5_000_000);
443            a.absorb(BTreeMap::from([("kim".to_owned(), Seen { signed_in: Some(7), operated: Some(1) })])).expect("absorb");
444            assert_eq!(a.seen("kim"), Seen { signed_in: Some(7), operated: Some(5_000_000) }, "the later of each");
445        }
446        assert_eq!(PrincipalActivity::open(db_at(&path), None, None).seen("kim").signed_in, Some(7));
447    }
448
449    #[test]
450    fn a_damaged_old_file_is_not_fatal() {
451        let dir = tempfile::tempdir().expect("tempdir");
452        let json = dir.path().join(ACTIVITY_FILE);
453        std::fs::write(&json, b"{not json").expect("write");
454        assert!(PrincipalActivity::open(db_at(&dir.path().join("control.redb")), Some(json), None).all().is_empty());
455    }
456}