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| {
115 crate::error::Error::ControlStore(format!("encoding last-seen stamps: {err}"))
116 })
117}
118
119pub 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
130pub 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
149fn 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 pub fn in_memory() -> PrincipalActivity {
168 PrincipalActivity {
169 db: None,
170 log: None,
171 inner: Mutex::new(Inner::default()),
172 }
173 }
174
175 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 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 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 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 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 pub fn signed_in(&self, principal_id: &str, now: i64) {
287 self.stamp(principal_id, now, |s| &mut s.signed_in);
288 }
289
290 pub fn operated(&self, principal_id: &str, now: i64) {
292 self.stamp(principal_id, now, |s| &mut s.operated);
293 }
294
295 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 pub fn all(&self) -> BTreeMap<String, Seen> {
306 self.lock().seen.clone()
307 }
308
309 pub fn is_dirty(&self) -> bool {
311 !self.lock().dirty.is_empty()
312 }
313
314 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 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 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}