Skip to main content

chronix_engine/memtable/
key.rs

1//! Memtable key types for ordering entries in the skip list.
2//!
3//! The [`MemtableKey`] orders entries by `(series_key_hash, timestamp,
4//! series_canonical)` so that all points for the same series are
5//! physically adjacent and ordered by time. The canonical form
6//! tie-breaker prevents hash collisions from silently overwriting
7//! distinct series data.
8//!
9//! [`MemtableEntry`] uses `Arc<str>` for measurement names, tag keys
10//! and tag values so that a [`StringInterner`](super::interner::StringInterner)
11//! can de-duplicate the thousands of identical strings that arise when
12//! many points share the same series.
13
14use std::cmp::Ordering;
15use std::hash::{Hash, Hasher};
16use std::sync::Arc;
17
18/// Compute a secondary hash (SipHash) of a canonical string for fast Ord comparison.
19/// Uses a different algorithm from FNV-1a to minimize correlated collisions.
20#[inline]
21fn canonical_siphash(s: &str) -> u64 {
22    let mut hasher = std::collections::hash_map::DefaultHasher::new();
23    s.hash(&mut hasher);
24    hasher.finish()
25}
26
27/// Key used for ordering entries in the memtable's skip list.
28///
29/// Points are sorted first by `series_key_hash` (fast grouping), then
30/// by `timestamp`, then by `series_canonical_hash2` (fast integer
31/// comparison), and finally by `series_canonical` as a rare-case
32/// tie-breaker for the astronomically unlikely double-hash collision.
33///
34/// `series_canonical` is `Arc<str>` (interned) to avoid
35/// per-insert heap allocation of duplicate canonical strings.
36///
37/// `series_canonical_hash2` (SipHash) provides a fast
38/// integer comparison path — the byte-by-byte string comparison only
39/// fires when both FNV and SipHash collide simultaneously.
40#[derive(Debug, Clone, Eq, PartialEq)]
41pub struct MemtableKey {
42    /// FNV-1a hash of the canonical series key (fast routing).
43    pub series_key_hash: u64,
44    /// Nanosecond-precision Unix epoch timestamp.
45    pub timestamp: i64,
46    /// Secondary SipHash of canonical form for fast Ord.
47    series_canonical_hash2: u64,
48    /// Interned canonical series key — collision-proof tie-breaker.
49    pub series_canonical: Arc<str>,
50}
51
52impl Hash for MemtableKey {
53    fn hash<H: Hasher>(&self, state: &mut H) {
54        self.series_key_hash.hash(state);
55        self.timestamp.hash(state);
56        self.series_canonical.hash(state);
57    }
58}
59
60impl MemtableKey {
61    /// Create a new memtable key.
62    ///
63    /// Accepts `Arc<str>` (interned) instead of `String`.
64    #[inline]
65    #[must_use]
66    pub fn new(series_key_hash: u64, timestamp: i64, series_canonical: Arc<str>) -> Self {
67        let series_canonical_hash2 = canonical_siphash(&series_canonical);
68        Self {
69            series_key_hash,
70            timestamp,
71            series_canonical_hash2,
72            series_canonical,
73        }
74    }
75
76    /// Create a lower-bound key for range scans.
77    ///
78    /// Uses `u64::MIN` for the secondary hash and an empty string so
79    /// this key sorts before all real keys with the same `(hash, ts)`.
80    #[inline]
81    #[must_use]
82    pub fn lower_bound(series_key_hash: u64, timestamp: i64) -> Self {
83        Self {
84            series_key_hash,
85            timestamp,
86            series_canonical_hash2: 0,
87            series_canonical: Arc::from(""),
88        }
89    }
90
91    /// Create an upper-bound key for range scans.
92    ///
93    /// Uses `u64::MAX` for the secondary hash and a max-codepoint string
94    /// so this key sorts after all real keys with the same `(hash, ts)`.
95    #[inline]
96    #[must_use]
97    pub fn upper_bound(series_key_hash: u64, timestamp: i64) -> Self {
98        Self {
99            series_key_hash,
100            timestamp,
101            series_canonical_hash2: u64::MAX,
102            series_canonical: Arc::from("\u{10FFFF}"),
103        }
104    }
105}
106
107impl Ord for MemtableKey {
108    fn cmp(&self, other: &Self) -> Ordering {
109        self.series_key_hash
110            .cmp(&other.series_key_hash)
111            .then(self.timestamp.cmp(&other.timestamp))
112            // Fast integer comparison — string fallback
113            // only fires on double-hash collision (probability ~2^-128).
114            .then(self.series_canonical_hash2.cmp(&other.series_canonical_hash2))
115            .then_with(|| self.series_canonical.cmp(&other.series_canonical))
116    }
117}
118
119impl PartialOrd for MemtableKey {
120    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
121        Some(self.cmp(other))
122    }
123}
124
125/// Value stored in the memtable's skip list alongside each key.
126///
127/// Holds one point's fields. Measurement names, tag keys, tag values and
128/// field keys are interned `Arc<str>`s, and the whole tag set is one
129/// `Arc<[..]>` shared by every point of the series in this memtable — a
130/// series' tags do not change from point to point, so storing them per
131/// point stored the same thing a thousand times an hour.
132///
133/// Not `BTreeMap`s: a one-entry `BTreeMap` allocates a leaf node sized for
134/// eleven, which is ~800 bytes per point of tree nodes nothing uses.
135/// `memtable_estimate_tracks_real_allocation` holds the size estimate to the
136/// allocator's number within a quarter.
137#[derive(Debug, Clone)]
138pub struct MemtableEntry {
139    /// Measurement name (interned).
140    pub measurement: Arc<str>,
141    /// Tag key-value pairs, sorted by key, shared per series.
142    pub tags: Arc<[(Arc<str>, Arc<str>)]>,
143    /// Field name (interned) → field value, sorted by name.
144    pub fields: Box<[(Arc<str>, chronix_core::FieldValue)]>,
145}
146
147/// What one skip-list node costs beyond its key and value: the node
148/// header, an average tower of two forward pointers, and the allocator's
149/// rounding. Calibrated by `memtable_estimate_tracks_real_allocation`.
150pub const NODE_OVERHEAD: usize = 12;
151
152impl MemtableEntry {
153    /// Heap bytes this entry owns exclusively — the struct itself and its
154    /// fields slice. Interned strings and the shared tag set are accounted
155    /// for once, where they are interned, not per point.
156    #[must_use]
157    pub fn estimated_size(&self) -> usize {
158        let mut size = std::mem::size_of::<Self>();
159        size += self.fields.len() * std::mem::size_of::<(Arc<str>, chronix_core::FieldValue)>();
160        for (_, v) in &self.fields {
161            if let chronix_core::FieldValue::String(s) = v {
162                size += s.len();
163            }
164        }
165        size
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn key_ordering_by_hash_first() {
175        let k1 = MemtableKey::new(1, 100, Arc::from("a"));
176        let k2 = MemtableKey::new(2, 50, Arc::from("a"));
177        assert!(k1 < k2, "lower hash should sort first");
178    }
179
180    #[test]
181    fn key_ordering_by_timestamp_second() {
182        let k1 = MemtableKey::new(1, 100, Arc::from("a"));
183        let k2 = MemtableKey::new(1, 200, Arc::from("a"));
184        assert!(k1 < k2, "same hash → lower timestamp first");
185    }
186
187    #[test]
188    fn key_ordering_by_canonical_third() {
189        let k1 = MemtableKey::new(1, 100, Arc::from("cpu\0host=a"));
190        let k2 = MemtableKey::new(1, 100, Arc::from("cpu\0host=b"));
191        // With the secondary hash, ordering may differ from pure string order,
192        // but the keys must remain distinct (collision-proof).
193        assert_ne!(k1, k2, "different canonical forms are distinct keys");
194    }
195
196    #[test]
197    fn key_equality() {
198        let k1 = MemtableKey::new(42, 999, Arc::from("cpu"));
199        let k2 = MemtableKey::new(42, 999, Arc::from("cpu"));
200        assert_eq!(k1, k2);
201    }
202
203    #[test]
204    fn hash_collision_does_not_merge_keys() {
205        // Simulate two distinct series that produce the same hash
206        let k1 = MemtableKey::new(42, 1000, Arc::from("series_a\0tag=1"));
207        let k2 = MemtableKey::new(42, 1000, Arc::from("series_b\0tag=2"));
208        assert_ne!(
209            k1, k2,
210            "collision-proof: different canonical forms are different keys"
211        );
212    }
213
214    #[test]
215    fn entry_estimated_size() {
216        let entry = MemtableEntry {
217            measurement: Arc::from("cpu"),
218            tags: vec![(Arc::from("host"), Arc::from("a"))].into(),
219            fields: vec![(Arc::from("value"), chronix_core::FieldValue::F64(42.0))].into(),
220        };
221        let size = entry.estimated_size();
222        assert!(size >= std::mem::size_of::<MemtableEntry>() + 16);
223        // A string field's bytes are the entry's own.
224        let with_string = MemtableEntry {
225            fields: vec![(
226                Arc::from("s"),
227                chronix_core::FieldValue::String("hello".into()),
228            )]
229            .into(),
230            ..entry
231        };
232        assert!(with_string.estimated_size() >= size + 5);
233    }
234}