summaryrefslogtreecommitdiffstats
path: root/src/compaction/local.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-20 00:51:39 +0200
committertv <tv@krebsco.de>2026-10-20 00:51:39 +0200
commita30499ffedb76dc08ba7e9c9b2ed4eb3f921a5df (patch)
treeb783cd8dac2d73c768586a08356248acfeb32c3b /src/compaction/local.rs
parentae34ebb3b70d529e93ea423f50b46a4ade15259b (diff)
add epoch switch
Diffstat (limited to 'src/compaction/local.rs')
-rw-r--r--src/compaction/local.rs198
1 files changed, 198 insertions, 0 deletions
diff --git a/src/compaction/local.rs b/src/compaction/local.rs
new file mode 100644
index 0000000..6290a5e
--- /dev/null
+++ b/src/compaction/local.rs
@@ -0,0 +1,198 @@
+//! Per-node, in-memory compaction state: snapshots built ahead of the switch, switch
+//! progress for reports, and archived documents of previous epochs.
+//!
+//! Nothing here is replicated or durable. After a restart the node simply has no
+//! archive and rebuilds snapshots when needed.
+
+use std::collections::HashMap;
+use std::sync::{Mutex, MutexGuard};
+
+use automerge::AutoCommit;
+use serde::Serialize;
+use tokio::time::Instant;
+
+use super::records::{unix_millis, Retention};
+use super::snapshot::Snapshot;
+use crate::replica::Epoch;
+
+#[derive(Default)]
+pub(crate) struct Compaction {
+ // A std mutex: every access is short and never spans an await.
+ inner: Mutex<Inner>,
+}
+
+#[derive(Default)]
+struct Inner {
+ /// Built by the coordinator at commit time; consumed by the local switch.
+ prebuilt: HashMap<String, Snapshot>,
+ /// When this node first saw a compaction committed.
+ first_seen: HashMap<String, Instant>,
+ status: HashMap<String, LocalStatus>,
+ archive: Vec<Archived>,
+}
+
+/// What happened to a compaction on this node.
+#[derive(Clone, Debug, Default, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct LocalStatus {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) switched_at: Option<u64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) replayed_paths: Option<usize>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) failed_paths: Option<usize>,
+ /// Why the switch did not happen (yet).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) error: Option<String>,
+}
+
+struct Archived {
+ epoch: Epoch,
+ // Kept for later steps (serving or auditing old epochs); not read yet.
+ #[allow(dead_code)]
+ doc: AutoCommit,
+ archived_at: u64,
+ retention: Retention,
+}
+
+/// Summary of an archived epoch.
+#[derive(Clone, Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct ArchivedEpoch {
+ pub(crate) generation: u64,
+ pub(crate) id: String,
+ pub(crate) archived_at: u64,
+}
+
+impl Compaction {
+ fn lock(&self) -> MutexGuard<'_, Inner> {
+ self.inner
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ }
+
+ pub(crate) fn store_prebuilt(&self, id: &str, snapshot: Snapshot) {
+ self.lock().prebuilt.insert(id.to_owned(), snapshot);
+ }
+
+ pub(crate) fn take_prebuilt(&self, id: &str) -> Option<Snapshot> {
+ self.lock().prebuilt.remove(id)
+ }
+
+ /// When this node first saw the compaction committed (now, on the first call).
+ pub(crate) fn first_seen(&self, id: &str) -> Instant {
+ *self
+ .lock()
+ .first_seen
+ .entry(id.to_owned())
+ .or_insert_with(Instant::now)
+ }
+
+ pub(crate) fn status(&self, id: &str) -> Option<LocalStatus> {
+ self.lock().status.get(id).cloned()
+ }
+
+ pub(crate) fn set_status(&self, id: &str, status: LocalStatus) {
+ self.lock().status.insert(id.to_owned(), status);
+ }
+
+ /// Keeps the previous epoch's document, then drops what has outlived its retention.
+ pub(crate) fn archive(
+ &self,
+ epoch: Epoch,
+ doc: AutoCommit,
+ retention: Retention,
+ current_generation: u64,
+ ) {
+ let mut inner = self.lock();
+ inner.archive.push(Archived {
+ epoch,
+ doc,
+ archived_at: unix_millis(),
+ retention,
+ });
+ prune(&mut inner.archive, current_generation);
+ }
+
+ pub(crate) fn prune_archive(&self, current_generation: u64) {
+ prune(&mut self.lock().archive, current_generation);
+ }
+
+ pub(crate) fn archived_epochs(&self) -> Vec<ArchivedEpoch> {
+ self.lock()
+ .archive
+ .iter()
+ .map(|archived| ArchivedEpoch {
+ generation: archived.epoch.generation,
+ id: archived.epoch.id_hex(),
+ archived_at: archived.archived_at,
+ })
+ .collect()
+ }
+}
+
+/// An archive is kept until every limit of its retention has passed.
+fn prune(archive: &mut Vec<Archived>, current_generation: u64) {
+ let now = unix_millis();
+ archive.retain(|archived| {
+ let within_epochs = archived.retention.epochs.is_some_and(|epochs| {
+ current_generation.saturating_sub(archived.epoch.generation) <= epochs
+ });
+ let within_time = archived.retention.seconds.is_some_and(|seconds| {
+ now.saturating_sub(archived.archived_at) <= seconds.saturating_mul(1000)
+ });
+ within_epochs || within_time
+ });
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn archive_retention() {
+ let compaction = Compaction::default();
+ let epoch = |generation| Epoch {
+ generation,
+ id: [generation as u8; 32],
+ };
+
+ // Kept by epoch count only.
+ compaction.archive(
+ epoch(0),
+ AutoCommit::new(),
+ Retention {
+ epochs: Some(1),
+ seconds: None,
+ },
+ 1,
+ );
+ assert_eq!(compaction.archived_epochs().len(), 1);
+ compaction.prune_archive(2);
+ assert!(compaction.archived_epochs().is_empty());
+
+ // Kept by time while the epoch count has passed.
+ compaction.archive(
+ epoch(1),
+ AutoCommit::new(),
+ Retention {
+ epochs: Some(0),
+ seconds: Some(3600),
+ },
+ 5,
+ );
+ assert_eq!(compaction.archived_epochs().len(), 1);
+
+ // No limits at all: not kept.
+ compaction.archive(
+ epoch(2),
+ AutoCommit::new(),
+ Retention {
+ epochs: None,
+ seconds: None,
+ },
+ 3,
+ );
+ assert_eq!(compaction.archived_epochs().len(), 1);
+ }
+}