Skip to main content

crafty_core/
compaction.rs

1//! Automatic log compaction policy (pure; runtime executes [`RaftNode::compact`]).
2//!
3//! The runtime snapshots applied state and purges the log prefix once either
4//! threshold is reached — whichever comes first when both are configured.
5
6use crafty_proto::{EntryPayload, LogEntry, LogIndex};
7
8use crate::RaftNode;
9
10/// Default retained applied entries before auto-compaction (Tier 1 ops).
11pub const DEFAULT_COMPACT_ENTRIES: u64 = 1024;
12
13/// Default retained applied log bytes before auto-compaction (~4 MiB).
14pub const DEFAULT_COMPACT_BYTES: u64 = 4 * 1024 * 1024;
15
16/// When the runtime should automatically compact the Raft log.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct CompactionPolicy {
19    /// Compact when applied entries since the last snapshot reach this count.
20    /// `None` disables the entry threshold.
21    pub max_entries: Option<u64>,
22    /// Compact when applied log bytes since the last snapshot reach this size.
23    /// `None` disables the byte threshold.
24    pub max_bytes: Option<u64>,
25}
26
27impl CompactionPolicy {
28    /// Disable automatic compaction (`compact()` remains available manually).
29    #[must_use]
30    pub fn disabled() -> Self {
31        Self {
32            max_entries: None,
33            max_bytes: None,
34        }
35    }
36
37    /// Entry and byte thresholds with crafty defaults.
38    #[must_use]
39    pub fn default_auto() -> Self {
40        Self {
41            max_entries: Some(DEFAULT_COMPACT_ENTRIES),
42            max_bytes: Some(DEFAULT_COMPACT_BYTES),
43        }
44    }
45
46    /// Compact after `entries` applied log entries beyond the snapshot boundary.
47    #[must_use]
48    pub fn entries(entries: u64) -> Self {
49        Self {
50            max_entries: Some(entries),
51            max_bytes: None,
52        }
53    }
54
55    /// Whether every threshold is unset.
56    #[must_use]
57    pub fn is_disabled(&self) -> bool {
58        self.max_entries.is_none() && self.max_bytes.is_none()
59    }
60}
61
62impl Default for CompactionPolicy {
63    fn default() -> Self {
64        Self::default_auto()
65    }
66}
67
68/// Observed log retention relative to the last snapshot.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct CompactionStats {
71    /// Highest index covered by the current snapshot (0 if none).
72    pub snapshot_index: LogIndex,
73    /// Highest index applied to the state machine.
74    pub last_applied: LogIndex,
75    /// Applied entries not yet compacted (`last_applied - snapshot_index`).
76    pub compactable_entries: u64,
77    /// Estimated byte size of the compactable prefix.
78    pub compactable_bytes: u64,
79}
80
81/// Collect compaction stats from a live node view.
82#[must_use]
83pub fn compaction_stats(node: &RaftNode) -> CompactionStats {
84    CompactionStats {
85        snapshot_index: node.snapshot_index(),
86        last_applied: node.last_applied(),
87        compactable_entries: node.compactable_entries(),
88        compactable_bytes: node.compactable_log_bytes(),
89    }
90}
91
92/// Whether `policy` says the runtime should run [`RaftNode::compact`] now.
93#[must_use]
94pub fn should_compact(policy: &CompactionPolicy, stats: &CompactionStats) -> bool {
95    if policy.is_disabled() || stats.compactable_entries == 0 {
96        return false;
97    }
98    if policy
99        .max_entries
100        .is_some_and(|limit| stats.compactable_entries >= limit)
101    {
102        return true;
103    }
104    policy
105        .max_bytes
106        .is_some_and(|limit| stats.compactable_bytes >= limit)
107}
108
109/// Rough on-disk size of one log entry for byte-threshold policy.
110#[must_use]
111pub fn entry_estimated_bytes(entry: &LogEntry) -> u64 {
112    const FIXED: u64 = 16; // term + index
113    FIXED
114        + match &entry.payload {
115            EntryPayload::Noop => 1,
116            EntryPayload::Command(bytes) => bytes.len() as u64,
117            EntryPayload::Membership(m) => {
118                ((m.voters.len() + m.voters_outgoing.len() + m.learners.len()) * 8) as u64 + 8
119            }
120            EntryPayload::Catalog(c) => match c {
121                crafty_proto::CatalogCommand::AddGroups { new_groups, .. } => {
122                    new_groups.len() as u64 * 4 + 8
123                }
124            },
125            EntryPayload::SagaJournal(c) => c.record.len() as u64 + 32,
126            EntryPayload::TwoPhasePrepare(c) => {
127                c.tx_id.len() as u64 + c.route_key.len() as u64 + c.command.len() as u64 + 32
128            }
129            EntryPayload::TwoPhaseAbort(c) => c.tx_id.len() as u64 + c.route_key.len() as u64 + 32,
130            EntryPayload::TwoPhaseJournal(c) => c.record.len() as u64 + 32,
131            EntryPayload::QueueAutoscalePolicy(c) => {
132                c.stream.len() as u64
133                    + c.worker
134                        .as_ref()
135                        .map_or(0, |w| w.worker_group.len() as u64 + 32)
136                    + c.membership.as_ref().map_or(0, |_| 32)
137                    + 16
138            }
139        }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::Config;
146    use crafty_proto::NodeId;
147
148    fn node(id: u64, members: &[u64]) -> RaftNode {
149        RaftNode::new(
150            NodeId(id),
151            members.iter().copied().map(NodeId),
152            Config::default(),
153        )
154    }
155
156    #[test]
157    fn disabled_policy_never_triggers() {
158        let n = node(1, &[1]);
159        let stats = compaction_stats(&n);
160        assert!(!should_compact(&CompactionPolicy::disabled(), &stats));
161    }
162
163    #[test]
164    fn entry_threshold_triggers() {
165        let policy = CompactionPolicy::entries(3);
166        assert!(should_compact(
167            &policy,
168            &CompactionStats {
169                snapshot_index: LogIndex(0),
170                last_applied: LogIndex(3),
171                compactable_entries: 3,
172                compactable_bytes: 0,
173            }
174        ));
175        assert!(!should_compact(
176            &policy,
177            &CompactionStats {
178                snapshot_index: LogIndex(0),
179                last_applied: LogIndex(2),
180                compactable_entries: 2,
181                compactable_bytes: 0,
182            }
183        ));
184    }
185
186    #[test]
187    fn byte_threshold_triggers() {
188        let policy = CompactionPolicy {
189            max_entries: None,
190            max_bytes: Some(100),
191        };
192        assert!(should_compact(
193            &policy,
194            &CompactionStats {
195                snapshot_index: LogIndex(1),
196                last_applied: LogIndex(2),
197                compactable_entries: 1,
198                compactable_bytes: 100,
199            }
200        ));
201    }
202
203    #[test]
204    fn zero_compactable_never_triggers() {
205        let mut n = node(1, &[1]);
206        n.campaign();
207        let _ = n.take_outputs();
208        let stats = compaction_stats(&n);
209        assert_eq!(stats.compactable_entries, 1); // no-op applied
210        assert!(!should_compact(&CompactionPolicy::entries(1024), &stats));
211    }
212}