use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::control::ControlDb;
use crate::txlog::{Kind, Log, Meta};
pub const ACTIVITY_FILE: &str = "principal-activity.json";
pub const IMPORTED_MARK: &str = "principal-activity-json-imported";
pub const GRANULARITY: i64 = 60_000;
pub const FLUSH_EVERY: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Seen {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signed_in: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operated: Option<i64>,
}
impl Seen {
fn merge(&mut self, other: Seen) {
self.signed_in = self.signed_in.max(other.signed_in);
self.operated = self.operated.max(other.operated);
}
}
#[derive(Default)]
struct Inner {
seen: BTreeMap<String, Seen>,
dirty: BTreeSet<String>,
}
pub struct PrincipalActivity {
db: Option<Arc<ControlDb>>,
log: Option<Arc<Log>>,
inner: Mutex<Inner>,
}
pub fn encode_batch(rows: &BTreeMap<String, Seen>) -> crate::error::Result<Vec<u8>> {
serde_json::to_vec(rows).map_err(|err| {
crate::error::Error::ControlStore(format!("encoding last-seen stamps: {err}"))
})
}
pub fn encode_rows(rows: &[(String, Vec<u8>)]) -> crate::error::Result<Vec<u8>> {
let mut map = BTreeMap::new();
for (id, raw) in rows {
if let Ok(seen) = serde_json::from_slice::<Seen>(raw) {
map.insert(id.clone(), seen);
}
}
encode_batch(&map)
}
pub fn decode_batch(blob: &[u8]) -> crate::error::Result<BTreeMap<String, Seen>> {
serde_json::from_slice(blob).map_err(|err| {
crate::error::Error::ControlStore(format!("a last-seen batch does not parse: {err}"))
})
}
fn row_of(id: &str, seen: &Seen) -> Option<(String, Vec<u8>)> {
serde_json::to_vec(seen).ok().map(|v| (id.to_owned(), v))
}
impl std::fmt::Debug for PrincipalActivity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PrincipalActivity")
.field("db", &self.db.is_some())
.finish_non_exhaustive()
}
}
fn advance(slot: &mut Option<i64>, now: i64) -> bool {
match *slot {
Some(last) if now.saturating_sub(last) < GRANULARITY => false,
_ => {
*slot = Some(now);
true
}
}
}
fn warn(what: &str, err: &dyn std::fmt::Display) {
tracing::warn!(target: "gunnar::activity", error = %err, "{what}");
}
impl PrincipalActivity {
pub fn in_memory() -> PrincipalActivity {
PrincipalActivity {
db: None,
log: None,
inner: Mutex::new(Inner::default()),
}
}
pub fn open(
db: Arc<ControlDb>,
legacy_json: Option<PathBuf>,
log: Option<Arc<Log>>,
) -> PrincipalActivity {
let mut seen = BTreeMap::new();
match db.principal_activity() {
Ok(rows) => {
for (id, raw) in rows {
match serde_json::from_slice::<Seen>(&raw) {
Ok(s) => {
seen.insert(id, s);
}
Err(err) => {
warn("a last-seen row does not parse; it is started over", &err)
}
}
}
}
Err(err) => warn(
"the last-seen table could not be read; starting it over",
&err,
),
}
let activity = PrincipalActivity {
db: Some(Arc::clone(&db)),
log,
inner: Mutex::new(Inner {
seen,
dirty: BTreeSet::new(),
}),
};
if let Some(path) = legacy_json {
activity.import_once(&db, path);
}
activity
}
fn import_once(&self, db: &ControlDb, path: PathBuf) {
match db.mark(IMPORTED_MARK) {
Ok(Some(_)) => return,
Ok(None) => {}
Err(err) => return warn("the last-seen import marker could not be read", &err),
}
let old: BTreeMap<String, Seen> = match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|err| {
warn(
"the old principal-activity.json does not parse; it is not imported",
&err,
);
BTreeMap::new()
}),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return,
Err(err) => {
warn(
"the old principal-activity.json could not be read; it is not imported",
&err,
);
BTreeMap::new()
}
};
let rows: Vec<(String, Vec<u8>)> = {
let mut inner = self.lock();
for (id, s) in old {
inner.seen.entry(id.clone()).or_default().merge(s);
inner.dirty.insert(id);
}
let inner = &*inner;
inner
.seen
.iter()
.filter_map(|(id, s)| row_of(id, s))
.collect()
};
if let Err(err) = db.put_principal_activity(&rows, Some(IMPORTED_MARK)) {
warn("the old principal-activity.json could not be written into the table; tried again next start", &err);
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
self.inner.lock().unwrap_or_else(|p| p.into_inner())
}
fn stamp(&self, principal_id: &str, now: i64, pick: fn(&mut Seen) -> &mut Option<i64>) {
if principal_id.is_empty() {
return;
}
let mut inner = self.lock();
if let Some(seen) = inner.seen.get_mut(principal_id) {
if advance(pick(seen), now) && !inner.dirty.contains(principal_id) {
inner.dirty.insert(principal_id.to_owned());
}
return;
}
let mut seen = Seen::default();
advance(pick(&mut seen), now);
inner.seen.insert(principal_id.to_owned(), seen);
inner.dirty.insert(principal_id.to_owned());
}
pub fn signed_in(&self, principal_id: &str, now: i64) {
self.stamp(principal_id, now, |s| &mut s.signed_in);
}
pub fn operated(&self, principal_id: &str, now: i64) {
self.stamp(principal_id, now, |s| &mut s.operated);
}
pub fn seen(&self, principal_id: &str) -> Seen {
self.lock()
.seen
.get(principal_id)
.copied()
.unwrap_or_default()
}
pub fn all(&self) -> BTreeMap<String, Seen> {
self.lock().seen.clone()
}
pub fn is_dirty(&self) -> bool {
!self.lock().dirty.is_empty()
}
pub fn flush(&self) -> crate::error::Result<bool> {
let Some(db) = &self.db else {
return Ok(false);
};
let (names, batch) = {
let mut inner = self.lock();
if inner.dirty.is_empty() {
return Ok(false);
}
let names = std::mem::take(&mut inner.dirty);
let batch: BTreeMap<String, Seen> = names
.iter()
.filter_map(|id| inner.seen.get(id).map(|s| (id.clone(), *s)))
.collect();
(names, batch)
};
let rows: Vec<(String, Vec<u8>)> =
batch.iter().filter_map(|(id, s)| row_of(id, s)).collect();
let written = (|| {
let framing = self.log.as_ref().filter(|log| !log.is_follower());
let _m = framing.map(|log| log.mutation());
if let Some(log) = framing {
log.append(Kind::Activity, &Meta::Activity {}, &encode_batch(&batch)?)
.map_err(|err| {
crate::error::Error::ControlStore(format!(
"framing last-seen stamps: {err}"
))
})?;
}
db.put_principal_activity(&rows, None)
})();
if let Err(err) = written {
self.lock().dirty.extend(names);
return Err(err);
}
Ok(true)
}
pub fn absorb(&self, rows: BTreeMap<String, Seen>) -> crate::error::Result<()> {
let merged: Vec<(String, Vec<u8>)> = {
let mut inner = self.lock();
rows.into_iter()
.filter_map(|(id, s)| {
let slot = inner.seen.entry(id.clone()).or_default();
slot.merge(s);
row_of(&id, slot)
})
.collect()
};
match &self.db {
Some(db) if !merged.is_empty() => db.put_principal_activity(&merged, None),
_ => Ok(()),
}
}
fn flush_logged(&self) {
if let Err(err) = self.flush() {
warn(
"the last-seen rows could not be written; they are kept in memory and tried again",
&err,
);
}
}
}
impl Drop for PrincipalActivity {
fn drop(&mut self) {
self.flush_logged();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn db_at(path: &std::path::Path) -> Arc<ControlDb> {
crate::control::open_control_with_db(path)
.expect("control store")
.2
}
#[test]
fn a_stamp_moves_only_past_the_grain_and_never_backwards() {
let a = PrincipalActivity::in_memory();
a.operated("kim", 1_000_000);
assert_eq!(a.seen("kim").operated, Some(1_000_000));
a.operated("kim", 1_000_000 + GRANULARITY - 1);
assert_eq!(a.seen("kim").operated, Some(1_000_000), "inside the grain");
a.operated("kim", 1_000_000 - 5 * GRANULARITY);
assert_eq!(
a.seen("kim").operated,
Some(1_000_000),
"an older stamp does not move it back"
);
a.operated("kim", 1_000_000 + GRANULARITY);
assert_eq!(a.seen("kim").operated, Some(1_000_000 + GRANULARITY));
assert_eq!(a.seen("kim").signed_in, None, "the two stamps are apart");
assert_eq!(a.seen("nobody"), Seen::default());
}
#[test]
fn a_flush_is_one_commit_for_many_stamps_and_none_when_nothing_moved() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("control.redb");
{
let db = db_at(&path);
let a = PrincipalActivity::open(Arc::clone(&db), None, None);
let before = db.durable_commits();
assert!(!a.flush().expect("flush"), "nothing moved, nothing written");
a.signed_in("kim", 5_000_000);
for i in 0..1_000 {
a.operated("kim", 5_000_000 + i);
}
a.operated("ann", 6_000_000);
assert_eq!(db.durable_commits(), before, "a stamp writes nothing");
assert!(a.flush().expect("flush"));
assert_eq!(
db.durable_commits(),
before + 1,
"one transaction for the whole batch"
);
assert!(
!a.flush().expect("flush"),
"the second flush has nothing to write"
);
a.operated("kim", 5_000_000 + 10);
assert!(
!a.is_dirty(),
"a stamp inside the grain does not make a write"
);
assert_eq!(db.durable_commits(), before + 1);
}
let a = PrincipalActivity::open(db_at(&path), None, None);
assert_eq!(
a.seen("kim"),
Seen {
signed_in: Some(5_000_000),
operated: Some(5_000_000)
}
);
assert_eq!(a.seen("ann").operated, Some(6_000_000));
}
#[test]
fn the_last_stamps_are_written_when_the_record_is_dropped() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("control.redb");
PrincipalActivity::open(db_at(&path), None, None).signed_in("kim", 7);
assert_eq!(
PrincipalActivity::open(db_at(&path), None, None)
.seen("kim")
.signed_in,
Some(7)
);
}
#[test]
fn an_old_json_file_is_imported_once_and_left() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("control.redb");
let json = dir.path().join(ACTIVITY_FILE);
std::fs::write(&json, br#"{"kim":{"signed_in":1000,"operated":2000}}"#).expect("write");
assert_eq!(
PrincipalActivity::open(db_at(&path), Some(json.clone()), None).seen("kim"),
Seen {
signed_in: Some(1000),
operated: Some(2000)
}
);
assert!(json.exists(), "the file is left");
std::fs::write(&json, br#"{"kim":{"signed_in":9000}}"#).expect("rewrite");
assert_eq!(
PrincipalActivity::open(db_at(&path), Some(json), None)
.seen("kim")
.signed_in,
Some(1000),
"read once"
);
}
#[test]
fn a_flush_on_an_originating_box_is_one_frame_and_a_follower_frames_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
let log = Arc::new(Log::open(dir.path().join("txlog")).expect("log"));
let a = PrincipalActivity::open(
db_at(&dir.path().join("control.redb")),
None,
Some(Arc::clone(&log)),
);
a.signed_in("kim", 1);
a.operated("ann", 2);
assert!(a.flush().expect("flush"));
assert_eq!(log.head_seq(), 1, "one frame for the batch");
let e = log.read(1).expect("the frame");
assert_eq!(e.kind(), Some(Kind::Activity));
let batch = decode_batch(&e.blob).expect("decodes");
assert_eq!(batch.get("kim").and_then(|s| s.signed_in), Some(1));
assert_eq!(batch.get("ann").and_then(|s| s.operated), Some(2));
log.set_follower(true);
a.operated("kim", 10 * GRANULARITY);
assert!(a.flush().expect("a follower still writes its table"));
assert_eq!(log.head_seq(), 1, "a follower frames nothing");
}
#[test]
fn absorbed_stamps_are_merged_and_written_without_a_frame() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("control.redb");
{
let a = PrincipalActivity::open(db_at(&path), None, None);
a.operated("kim", 5_000_000);
a.absorb(BTreeMap::from([(
"kim".to_owned(),
Seen {
signed_in: Some(7),
operated: Some(1),
},
)]))
.expect("absorb");
assert_eq!(
a.seen("kim"),
Seen {
signed_in: Some(7),
operated: Some(5_000_000)
},
"the later of each"
);
}
assert_eq!(
PrincipalActivity::open(db_at(&path), None, None)
.seen("kim")
.signed_in,
Some(7)
);
}
#[test]
fn a_damaged_old_file_is_not_fatal() {
let dir = tempfile::tempdir().expect("tempdir");
let json = dir.path().join(ACTIVITY_FILE);
std::fs::write(&json, b"{not json").expect("write");
assert!(
PrincipalActivity::open(db_at(&dir.path().join("control.redb")), Some(json), None)
.all()
.is_empty()
);
}
}