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| {
115        crate::error::Error::ControlStore(format!("encoding last-seen stamps: {err}"))
116    })
117}
118
119/// [`encode_batch`] over the table's raw rows (each already one encoded [`Seen`]).
120pub fn encode_rows(rows: &[(String, Vec<u8>)]) -> crate::error::Result<Vec<u8>> {
121    let mut map = BTreeMap::new();
122    for (id, raw) in rows {
123        if let Ok(seen) = serde_json::from_slice::<Seen>(raw) {
124            map.insert(id.clone(), seen);
125        }
126    }
127    encode_batch(&map)
128}
129
130/// A frame's or a snapshot item's blob back to its stamps.
131pub fn decode_batch(blob: &[u8]) -> crate::error::Result<BTreeMap<String, Seen>> {
132    serde_json::from_slice(blob).map_err(|err| {
133        crate::error::Error::ControlStore(format!("a last-seen batch does not parse: {err}"))
134    })
135}
136
137fn row_of(id: &str, seen: &Seen) -> Option<(String, Vec<u8>)> {
138    serde_json::to_vec(seen).ok().map(|v| (id.to_owned(), v))
139}
140
141impl std::fmt::Debug for PrincipalActivity {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        f.debug_struct("PrincipalActivity")
144            .field("db", &self.db.is_some())
145            .finish_non_exhaustive()
146    }
147}
148
149/// Move `slot` to `now` when it is empty or at least [`GRANULARITY`] older.
150/// Never backwards. `true` when it moved.
151fn advance(slot: &mut Option<i64>, now: i64) -> bool {
152    match *slot {
153        Some(last) if now.saturating_sub(last) < GRANULARITY => false,
154        _ => {
155            *slot = Some(now);
156            true
157        }
158    }
159}
160
161fn warn(what: &str, err: &dyn std::fmt::Display) {
162    tracing::warn!(target: "gunnar::activity", error = %err, "{what}");
163}
164
165impl PrincipalActivity {
166    /// Stamps kept in memory only (a test, a tool).
167    pub fn in_memory() -> PrincipalActivity {
168        PrincipalActivity {
169            db: None,
170            log: None,
171            inner: Mutex::new(Inner::default()),
172        }
173    }
174
175    /// The stamps kept in `db`'s `principal_activity` table, read back — and,
176    /// the first time, the old [`ACTIVITY_FILE`] at `legacy_json` read into
177    /// it (the file is left). A table that cannot be read, or a file that does
178    /// not parse, is said at `warn!` and started over: this is bookkeeping,
179    /// and a box must never refuse to start over it.
180    pub fn open(
181        db: Arc<ControlDb>,
182        legacy_json: Option<PathBuf>,
183        log: Option<Arc<Log>>,
184    ) -> PrincipalActivity {
185        let mut seen = BTreeMap::new();
186        match db.principal_activity() {
187            Ok(rows) => {
188                for (id, raw) in rows {
189                    match serde_json::from_slice::<Seen>(&raw) {
190                        Ok(s) => {
191                            seen.insert(id, s);
192                        }
193                        Err(err) => {
194                            warn("a last-seen row does not parse; it is started over", &err)
195                        }
196                    }
197                }
198            }
199            Err(err) => warn(
200                "the last-seen table could not be read; starting it over",
201                &err,
202            ),
203        }
204        let activity = PrincipalActivity {
205            db: Some(Arc::clone(&db)),
206            log,
207            inner: Mutex::new(Inner {
208                seen,
209                dirty: BTreeSet::new(),
210            }),
211        };
212        if let Some(path) = legacy_json {
213            activity.import_once(&db, path);
214        }
215        activity
216    }
217
218    /// Read the old JSON file into the table, once: its rows and the marker go
219    /// in one transaction, and the file stays where it is.
220    fn import_once(&self, db: &ControlDb, path: PathBuf) {
221        match db.mark(IMPORTED_MARK) {
222            Ok(Some(_)) => return,
223            Ok(None) => {}
224            Err(err) => return warn("the last-seen import marker could not be read", &err),
225        }
226        let old: BTreeMap<String, Seen> = match std::fs::read(&path) {
227            Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|err| {
228                warn(
229                    "the old principal-activity.json does not parse; it is not imported",
230                    &err,
231                );
232                BTreeMap::new()
233            }),
234            // No old file: nothing to import, and nothing marked.
235            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return,
236            Err(err) => {
237                warn(
238                    "the old principal-activity.json could not be read; it is not imported",
239                    &err,
240                );
241                BTreeMap::new()
242            }
243        };
244        let rows: Vec<(String, Vec<u8>)> = {
245            let mut inner = self.lock();
246            for (id, s) in old {
247                inner.seen.entry(id.clone()).or_default().merge(s);
248                // The twin gets them with the next flush's frame.
249                inner.dirty.insert(id);
250            }
251            let inner = &*inner;
252            inner
253                .seen
254                .iter()
255                .filter_map(|(id, s)| row_of(id, s))
256                .collect()
257        };
258        if let Err(err) = db.put_principal_activity(&rows, Some(IMPORTED_MARK)) {
259            warn("the old principal-activity.json could not be written into the table; tried again next start", &err);
260        }
261    }
262
263    fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
264        self.inner.lock().unwrap_or_else(|p| p.into_inner())
265    }
266
267    fn stamp(&self, principal_id: &str, now: i64, pick: fn(&mut Seen) -> &mut Option<i64>) {
268        if principal_id.is_empty() {
269            return;
270        }
271        let mut inner = self.lock();
272        // The common case — a stamp younger than the grain — allocates nothing.
273        if let Some(seen) = inner.seen.get_mut(principal_id) {
274            if advance(pick(seen), now) && !inner.dirty.contains(principal_id) {
275                inner.dirty.insert(principal_id.to_owned());
276            }
277            return;
278        }
279        let mut seen = Seen::default();
280        advance(pick(&mut seen), now);
281        inner.seen.insert(principal_id.to_owned(), seen);
282        inner.dirty.insert(principal_id.to_owned());
283    }
284
285    /// A console session was issued to `principal_id` at `now`.
286    pub fn signed_in(&self, principal_id: &str, now: i64) {
287        self.stamp(principal_id, now, |s| &mut s.signed_in);
288    }
289
290    /// `principal_id` made a call that changed something at `now`.
291    pub fn operated(&self, principal_id: &str, now: i64) {
292        self.stamp(principal_id, now, |s| &mut s.operated);
293    }
294
295    /// One principal's stamps.
296    pub fn seen(&self, principal_id: &str) -> Seen {
297        self.lock()
298            .seen
299            .get(principal_id)
300            .copied()
301            .unwrap_or_default()
302    }
303
304    /// Every principal's stamps.
305    pub fn all(&self) -> BTreeMap<String, Seen> {
306        self.lock().seen.clone()
307    }
308
309    /// Something was stamped that is not in the table yet.
310    pub fn is_dirty(&self) -> bool {
311        !self.lock().dirty.is_empty()
312    }
313
314    /// Write the rows that moved since the last flush: **one** `Activity`
315    /// frame for all of them (on a box that originates its log), then **one**
316    /// redb write transaction. `Ok(false)` when nothing moved (nothing is
317    /// written or framed) or this record keeps no database. A refused write
318    /// keeps the rows for the next flush.
319    pub fn flush(&self) -> crate::error::Result<bool> {
320        let Some(db) = &self.db else {
321            return Ok(false);
322        };
323        let (names, batch) = {
324            let mut inner = self.lock();
325            if inner.dirty.is_empty() {
326                return Ok(false);
327            }
328            let names = std::mem::take(&mut inner.dirty);
329            let batch: BTreeMap<String, Seen> = names
330                .iter()
331                .filter_map(|id| inner.seen.get(id).map(|s| (id.clone(), *s)))
332                .collect();
333            (names, batch)
334        };
335        let rows: Vec<(String, Vec<u8>)> =
336            batch.iter().filter_map(|(id, s)| row_of(id, s)).collect();
337        let written = (|| {
338            let framing = self.log.as_ref().filter(|log| !log.is_follower());
339            // The frame and the table under one mutation guard: a snapshot's cut sees both or
340            // neither.
341            let _m = framing.map(|log| log.mutation());
342            if let Some(log) = framing {
343                log.append(Kind::Activity, &Meta::Activity {}, &encode_batch(&batch)?)
344                    .map_err(|err| {
345                        crate::error::Error::ControlStore(format!(
346                            "framing last-seen stamps: {err}"
347                        ))
348                    })?;
349            }
350            db.put_principal_activity(&rows, None)
351        })();
352        if let Err(err) = written {
353            self.lock().dirty.extend(names);
354            return Err(err);
355        }
356        Ok(true)
357    }
358
359    /// **Take stamps from the primary** (a twin applying an `Activity` frame or a
360    /// snapshot item; a box being restored): each one merged in memory, the later
361    /// of each stamp kept, and the merged rows written to the table in one
362    /// transaction. Framed by nobody — it is already a frame.
363    pub fn absorb(&self, rows: BTreeMap<String, Seen>) -> crate::error::Result<()> {
364        let merged: Vec<(String, Vec<u8>)> = {
365            let mut inner = self.lock();
366            rows.into_iter()
367                .filter_map(|(id, s)| {
368                    let slot = inner.seen.entry(id.clone()).or_default();
369                    slot.merge(s);
370                    row_of(&id, slot)
371                })
372                .collect()
373        };
374        match &self.db {
375            Some(db) if !merged.is_empty() => db.put_principal_activity(&merged, None),
376            _ => Ok(()),
377        }
378    }
379
380    fn flush_logged(&self) {
381        if let Err(err) = self.flush() {
382            warn(
383                "the last-seen rows could not be written; they are kept in memory and tried again",
384                &err,
385            );
386        }
387    }
388}
389
390impl Drop for PrincipalActivity {
391    fn drop(&mut self) {
392        self.flush_logged();
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    fn db_at(path: &std::path::Path) -> Arc<ControlDb> {
401        crate::control::open_control_with_db(path)
402            .expect("control store")
403            .2
404    }
405
406    #[test]
407    fn a_stamp_moves_only_past_the_grain_and_never_backwards() {
408        let a = PrincipalActivity::in_memory();
409        a.operated("kim", 1_000_000);
410        assert_eq!(a.seen("kim").operated, Some(1_000_000));
411        a.operated("kim", 1_000_000 + GRANULARITY - 1);
412        assert_eq!(a.seen("kim").operated, Some(1_000_000), "inside the grain");
413        a.operated("kim", 1_000_000 - 5 * GRANULARITY);
414        assert_eq!(
415            a.seen("kim").operated,
416            Some(1_000_000),
417            "an older stamp does not move it back"
418        );
419        a.operated("kim", 1_000_000 + GRANULARITY);
420        assert_eq!(a.seen("kim").operated, Some(1_000_000 + GRANULARITY));
421        assert_eq!(a.seen("kim").signed_in, None, "the two stamps are apart");
422        assert_eq!(a.seen("nobody"), Seen::default());
423    }
424
425    #[test]
426    fn a_flush_is_one_commit_for_many_stamps_and_none_when_nothing_moved() {
427        let dir = tempfile::tempdir().expect("tempdir");
428        let path = dir.path().join("control.redb");
429        {
430            let db = db_at(&path);
431            let a = PrincipalActivity::open(Arc::clone(&db), None, None);
432            let before = db.durable_commits();
433            assert!(!a.flush().expect("flush"), "nothing moved, nothing written");
434            a.signed_in("kim", 5_000_000);
435            for i in 0..1_000 {
436                a.operated("kim", 5_000_000 + i);
437            }
438            a.operated("ann", 6_000_000);
439            assert_eq!(db.durable_commits(), before, "a stamp writes nothing");
440            assert!(a.flush().expect("flush"));
441            assert_eq!(
442                db.durable_commits(),
443                before + 1,
444                "one transaction for the whole batch"
445            );
446            assert!(
447                !a.flush().expect("flush"),
448                "the second flush has nothing to write"
449            );
450            a.operated("kim", 5_000_000 + 10);
451            assert!(
452                !a.is_dirty(),
453                "a stamp inside the grain does not make a write"
454            );
455            assert_eq!(db.durable_commits(), before + 1);
456        }
457        let a = PrincipalActivity::open(db_at(&path), None, None);
458        assert_eq!(
459            a.seen("kim"),
460            Seen {
461                signed_in: Some(5_000_000),
462                operated: Some(5_000_000)
463            }
464        );
465        assert_eq!(a.seen("ann").operated, Some(6_000_000));
466    }
467
468    #[test]
469    fn the_last_stamps_are_written_when_the_record_is_dropped() {
470        let dir = tempfile::tempdir().expect("tempdir");
471        let path = dir.path().join("control.redb");
472        PrincipalActivity::open(db_at(&path), None, None).signed_in("kim", 7);
473        assert_eq!(
474            PrincipalActivity::open(db_at(&path), None, None)
475                .seen("kim")
476                .signed_in,
477            Some(7)
478        );
479    }
480
481    #[test]
482    fn an_old_json_file_is_imported_once_and_left() {
483        let dir = tempfile::tempdir().expect("tempdir");
484        let path = dir.path().join("control.redb");
485        let json = dir.path().join(ACTIVITY_FILE);
486        std::fs::write(&json, br#"{"kim":{"signed_in":1000,"operated":2000}}"#).expect("write");
487        assert_eq!(
488            PrincipalActivity::open(db_at(&path), Some(json.clone()), None).seen("kim"),
489            Seen {
490                signed_in: Some(1000),
491                operated: Some(2000)
492            }
493        );
494        assert!(json.exists(), "the file is left");
495        std::fs::write(&json, br#"{"kim":{"signed_in":9000}}"#).expect("rewrite");
496        assert_eq!(
497            PrincipalActivity::open(db_at(&path), Some(json), None)
498                .seen("kim")
499                .signed_in,
500            Some(1000),
501            "read once"
502        );
503    }
504
505    #[test]
506    fn a_flush_on_an_originating_box_is_one_frame_and_a_follower_frames_nothing() {
507        let dir = tempfile::tempdir().expect("tempdir");
508        let log = Arc::new(Log::open(dir.path().join("txlog")).expect("log"));
509        let a = PrincipalActivity::open(
510            db_at(&dir.path().join("control.redb")),
511            None,
512            Some(Arc::clone(&log)),
513        );
514        a.signed_in("kim", 1);
515        a.operated("ann", 2);
516        assert!(a.flush().expect("flush"));
517        assert_eq!(log.head_seq(), 1, "one frame for the batch");
518        let e = log.read(1).expect("the frame");
519        assert_eq!(e.kind(), Some(Kind::Activity));
520        let batch = decode_batch(&e.blob).expect("decodes");
521        assert_eq!(batch.get("kim").and_then(|s| s.signed_in), Some(1));
522        assert_eq!(batch.get("ann").and_then(|s| s.operated), Some(2));
523        log.set_follower(true);
524        a.operated("kim", 10 * GRANULARITY);
525        assert!(a.flush().expect("a follower still writes its table"));
526        assert_eq!(log.head_seq(), 1, "a follower frames nothing");
527    }
528
529    #[test]
530    fn absorbed_stamps_are_merged_and_written_without_a_frame() {
531        let dir = tempfile::tempdir().expect("tempdir");
532        let path = dir.path().join("control.redb");
533        {
534            let a = PrincipalActivity::open(db_at(&path), None, None);
535            a.operated("kim", 5_000_000);
536            a.absorb(BTreeMap::from([(
537                "kim".to_owned(),
538                Seen {
539                    signed_in: Some(7),
540                    operated: Some(1),
541                },
542            )]))
543            .expect("absorb");
544            assert_eq!(
545                a.seen("kim"),
546                Seen {
547                    signed_in: Some(7),
548                    operated: Some(5_000_000)
549                },
550                "the later of each"
551            );
552        }
553        assert_eq!(
554            PrincipalActivity::open(db_at(&path), None, None)
555                .seen("kim")
556                .signed_in,
557            Some(7)
558        );
559    }
560
561    #[test]
562    fn a_damaged_old_file_is_not_fatal() {
563        let dir = tempfile::tempdir().expect("tempdir");
564        let json = dir.path().join(ACTIVITY_FILE);
565        std::fs::write(&json, b"{not json").expect("write");
566        assert!(
567            PrincipalActivity::open(db_at(&dir.path().join("control.redb")), Some(json), None)
568                .all()
569                .is_empty()
570        );
571    }
572}