1use 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
59pub const ACTIVITY_FILE: &str = "principal-activity.json";
62
63pub const IMPORTED_MARK: &str = "principal-activity-json-imported";
66
67pub const GRANULARITY: i64 = 60_000;
71
72pub const FLUSH_EVERY: Duration = Duration::from_secs(30);
74
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
78pub struct Seen {
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub signed_in: Option<i64>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub operated: Option<i64>,
85}
86
87impl Seen {
88 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 dirty: BTreeSet<String>,
100}
101
102pub struct PrincipalActivity {
105 db: Option<Arc<ControlDb>>,
106 log: Option<Arc<Log>>,
108 inner: Mutex<Inner>,
109}
110
111pub 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
117pub 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
128pub 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
143fn 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 pub fn in_memory() -> PrincipalActivity {
162 PrincipalActivity { db: None, log: None, inner: Mutex::new(Inner::default()) }
163 }
164
165 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 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 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 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 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 pub fn signed_in(&self, principal_id: &str, now: i64) {
251 self.stamp(principal_id, now, |s| &mut s.signed_in);
252 }
253
254 pub fn operated(&self, principal_id: &str, now: i64) {
256 self.stamp(principal_id, now, |s| &mut s.operated);
257 }
258
259 pub fn seen(&self, principal_id: &str) -> Seen {
261 self.lock().seen.get(principal_id).copied().unwrap_or_default()
262 }
263
264 pub fn all(&self) -> BTreeMap<String, Seen> {
266 self.lock().seen.clone()
267 }
268
269 pub fn is_dirty(&self) -> bool {
271 !self.lock().dirty.is_empty()
272 }
273
274 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 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 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}