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}