Skip to main content

nodedb_codec/
fsst.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! FSST (Fast Static Symbol Table) codec for string/log columns.
4//!
5//! Builds a lightweight dictionary of common substrings (1-8 bytes) and
6//! encodes strings as sequences of symbol table indices. Unlike whole-string
7//! dictionary encoding, FSST handles partial overlap — strings sharing
8//! prefixes or suffixes compress well even if no two strings are identical.
9//!
10//! Compression: 3-5x on string columns before any terminal compressor.
11//! Combined with lz4_flex terminal: 8-15x total on structured log data.
12//!
13//! Decompression: simple table lookup — fast enough to query directly
14//! over encoded data.
15//!
16//! Wire format:
17//! ```text
18//! [2 bytes] symbol count (LE u16, max 255)
19//! [symbol_count × (1 + len) bytes] symbol table: (len: u8, bytes: [u8; len])
20//! [4 bytes] total encoded length (LE u32)
21//! [4 bytes] string count (LE u32)
22//! [string_count × 4 bytes] encoded string offsets (cumulative LE u32)
23//! [N bytes] encoded data (symbol indices interleaved with escape+literal)
24//! ```
25//!
26//! Escape mechanism: byte value 255 followed by a literal byte encodes
27//! bytes not covered by any symbol. Symbol indices are 0..254.
28
29use std::mem::size_of;
30
31use crate::bounds::{
32    checked_add, checked_capacity, checked_mul, checked_range, decoded_len, encode_input_len,
33    encode_u32_len, u32_to_usize,
34};
35use crate::error::CodecError;
36
37/// Maximum number of symbols in the table (reserve 255 as escape).
38const MAX_SYMBOLS: usize = 255;
39
40/// Maximum symbol length in bytes.
41const MAX_SYMBOL_LEN: usize = 8;
42
43/// Escape byte: signals the next byte is a literal (not a symbol index).
44const ESCAPE: u8 = 255;
45
46/// Number of training passes over the input data.
47const TRAINING_ROUNDS: usize = 5;
48
49// ---------------------------------------------------------------------------
50// Symbol table
51// ---------------------------------------------------------------------------
52
53/// A trained FSST symbol table.
54#[derive(Debug, Clone)]
55struct SymbolTable {
56    /// Symbols sorted by length (longest first) for greedy matching.
57    symbols: Vec<Vec<u8>>,
58}
59
60impl SymbolTable {
61    /// Train a symbol table from a set of input strings.
62    ///
63    /// Uses iterative count-based selection: in each round, count how many
64    /// bytes each candidate n-gram would save, pick the best, repeat.
65    fn train(strings: &[&[u8]]) -> Self {
66        if strings.is_empty() {
67            return Self {
68                symbols: Vec::new(),
69            };
70        }
71
72        let mut symbols: Vec<Vec<u8>> = Vec::new();
73        let mut symbol_set: std::collections::HashSet<Vec<u8>> = std::collections::HashSet::new();
74        let mut candidates: std::collections::HashMap<Vec<u8>, usize> =
75            std::collections::HashMap::new();
76
77        for _round in 0..TRAINING_ROUNDS {
78            // Count n-gram frequencies in the data (after encoding with current table).
79            candidates.clear();
80
81            for s in strings {
82                // Scan for n-grams of length 1-8 that are NOT already covered by symbols.
83                let mut pos = 0;
84                while pos < s.len() {
85                    // Check if current position starts with a known symbol.
86                    let existing_match = longest_symbol_match(&symbols, s, pos);
87
88                    if existing_match > 0 {
89                        pos += existing_match;
90                        continue;
91                    }
92
93                    // No existing symbol matches — count new n-gram candidates.
94                    for len in 1..=MAX_SYMBOL_LEN.min(s.len() - pos) {
95                        let ngram = &s[pos..pos + len];
96                        *candidates.entry(ngram.to_vec()).or_insert(0) += 1;
97                    }
98                    pos += 1;
99                }
100            }
101
102            if candidates.is_empty() {
103                break;
104            }
105
106            // Score candidates by compression gain: frequency * (length - 1).
107            // Each symbol saves (length - 1) bytes per occurrence (1 byte for
108            // the symbol index vs `length` bytes raw).
109            let mut scored: Vec<(Vec<u8>, usize)> = candidates
110                .drain()
111                .map(|(ngram, freq)| {
112                    let gain = freq * (ngram.len().saturating_sub(1));
113                    (ngram, gain)
114                })
115                .filter(|(_, gain)| *gain > 0)
116                .collect();
117
118            scored.sort_by_key(|a| std::cmp::Reverse(a.1));
119
120            // Add top candidates that don't duplicate existing symbols.
121            for (ngram, _) in scored {
122                if symbols.len() >= MAX_SYMBOLS {
123                    break;
124                }
125                if symbol_set.insert(ngram.clone()) {
126                    symbols.push(ngram);
127                }
128            }
129        }
130
131        // Sort symbols longest-first for greedy matching.
132        symbols.sort_by_key(|a| std::cmp::Reverse(a.len()));
133
134        Self { symbols }
135    }
136
137    fn symbol_count(&self) -> usize {
138        self.symbols.len()
139    }
140}
141
142/// Find the longest symbol matching at position `pos` in `data`.
143/// Returns the match length (0 if no match).
144fn longest_symbol_match(symbols: &[Vec<u8>], data: &[u8], pos: usize) -> usize {
145    let remaining = &data[pos..];
146    for sym in symbols {
147        if remaining.starts_with(sym) {
148            return sym.len();
149        }
150    }
151    0
152}
153
154// ---------------------------------------------------------------------------
155// Public encode / decode API
156// ---------------------------------------------------------------------------
157
158/// Encode a batch of strings using FSST compression.
159///
160/// Trains a symbol table on the input, then encodes each string as a
161/// sequence of symbol indices and escaped literals.
162pub fn encode(strings: &[&[u8]]) -> Result<Vec<u8>, CodecError> {
163    encode_input_len(strings.len(), "FSST string count")?;
164    let input_bytes = strings.iter().try_fold(0usize, |total, string| {
165        checked_add(total, string.len(), "FSST input bytes")
166    })?;
167    decoded_len(input_bytes, "FSST input")?;
168    let table = SymbolTable::train(strings);
169
170    // Encode each string.
171    let mut encoded_strings: Vec<Vec<u8>> = Vec::with_capacity(strings.len());
172    for s in strings {
173        encoded_strings.push(encode_string(&table, s)?);
174    }
175
176    // Build wire format.
177    // Encoded strings with offset table.
178    let total_encoded = encoded_strings.iter().try_fold(0usize, |total, string| {
179        checked_add(total, string.len(), "FSST encoded bytes")
180    })?;
181    decoded_len(total_encoded, "FSST encoded bytes")?;
182    let total_encoded_u32 = encode_u32_len(total_encoded, "FSST encoded bytes")?;
183    let offset_bytes = checked_mul(strings.len(), 4, "FSST offsets")?;
184    decoded_len(offset_bytes, "FSST offsets")?;
185    let symbol_bytes = table.symbols.iter().try_fold(0usize, |total, symbol| {
186        checked_add(
187            checked_add(total, 1, "FSST symbols")?,
188            symbol.len(),
189            "FSST symbols",
190        )
191    })?;
192    let output_len = checked_add(
193        checked_add(
194            checked_add(2, symbol_bytes, "FSST output")?,
195            8,
196            "FSST output",
197        )?,
198        checked_add(offset_bytes, total_encoded, "FSST output")?,
199        "FSST output",
200    )?;
201    decoded_len(output_len, "FSST output")?;
202    let string_count = encode_input_len(strings.len(), "FSST string count")?;
203    let mut out = Vec::with_capacity(output_len);
204
205    // Symbol table.
206    out.extend_from_slice(&(table.symbol_count() as u16).to_le_bytes());
207    for sym in &table.symbols {
208        out.push(sym.len() as u8);
209        out.extend_from_slice(sym);
210    }
211    out.extend_from_slice(&total_encoded_u32.to_le_bytes());
212    out.extend_from_slice(&string_count.to_le_bytes());
213
214    // Cumulative offsets.
215    let mut offset = 0usize;
216    for es in &encoded_strings {
217        offset = checked_add(offset, es.len(), "FSST string offset")?;
218        out.extend_from_slice(&encode_u32_len(offset, "FSST string offset")?.to_le_bytes());
219    }
220
221    // Encoded data.
222    for es in &encoded_strings {
223        out.extend_from_slice(es);
224    }
225
226    Ok(out)
227}
228
229/// Decode FSST-compressed data back to strings.
230pub fn decode(data: &[u8]) -> Result<Vec<Vec<u8>>, CodecError> {
231    if data.len() < 2 {
232        return Err(CodecError::Truncated {
233            expected: 2,
234            actual: data.len(),
235        });
236    }
237
238    // Read symbol table.
239    let sym_count = usize::from(u16::from_le_bytes([data[0], data[1]]));
240    if sym_count > MAX_SYMBOLS {
241        return Err(CodecError::Corrupt {
242            detail: "FSST symbol count exceeds maximum".into(),
243        });
244    }
245    let mut pos = 2;
246    let symbol_capacity = checked_capacity(sym_count, size_of::<Vec<u8>>(), "FSST symbols")?;
247    let mut symbols: Vec<Vec<u8>> = Vec::with_capacity(symbol_capacity);
248
249    for _ in 0..sym_count {
250        let len = usize::from(checked_range(data, pos, 1, "FSST symbol length")?[0]);
251        pos = checked_add(pos, 1, "FSST symbol cursor")?;
252        if len == 0 || len > MAX_SYMBOL_LEN {
253            return Err(CodecError::Corrupt {
254                detail: "FSST symbol length is invalid".into(),
255            });
256        }
257        let symbol = checked_range(data, pos, len, "FSST symbol")?;
258        if symbols.iter().any(|existing| existing == symbol) {
259            return Err(CodecError::Corrupt {
260                detail: "duplicate FSST symbol".into(),
261            });
262        }
263        symbols.push(symbol.to_vec());
264        pos = checked_add(pos, len, "FSST symbol cursor")?;
265    }
266
267    // Read header.
268    let header = checked_range(data, pos, 8, "FSST header")?;
269    let total_encoded = u32_to_usize(
270        u32::from_le_bytes([header[0], header[1], header[2], header[3]]),
271        "FSST encoded length",
272    )?;
273    let string_count = u32_to_usize(
274        u32::from_le_bytes([header[4], header[5], header[6], header[7]]),
275        "FSST string count",
276    )?;
277    decoded_len(total_encoded, "FSST")?;
278    let container_bytes = checked_add(
279        checked_mul(string_count, size_of::<usize>(), "FSST offset allocation")?,
280        checked_mul(string_count, size_of::<Vec<u8>>(), "FSST result allocation")?,
281        "FSST container allocations",
282    )?;
283    decoded_len(container_bytes, "FSST containers")?;
284    pos = checked_add(pos, 8, "FSST header cursor")?;
285
286    // Read offsets.
287    let offsets_size = checked_mul(string_count, 4, "FSST offsets")?;
288    decoded_len(offsets_size, "FSST offsets")?;
289    let offsets_data = checked_range(data, pos, offsets_size, "FSST offsets")?;
290    let offset_capacity = checked_capacity(string_count, size_of::<usize>(), "FSST offsets")?;
291    let mut offsets = Vec::with_capacity(offset_capacity);
292    for i in 0..string_count {
293        let off_pos = checked_mul(i, 4, "FSST offset position")?;
294        offsets.push(u32_to_usize(
295            u32::from_le_bytes([
296                offsets_data[off_pos],
297                offsets_data[off_pos + 1],
298                offsets_data[off_pos + 2],
299                offsets_data[off_pos + 3],
300            ]),
301            "FSST string offset",
302        )?);
303    }
304    pos = checked_add(pos, offsets_size, "FSST data cursor")?;
305    let encoded_data = checked_range(data, pos, total_encoded, "FSST encoded data")?;
306    if checked_add(pos, total_encoded, "FSST frame end")? != data.len() {
307        return Err(CodecError::Corrupt {
308            detail: "trailing bytes after FSST frame".into(),
309        });
310    }
311
312    // Decode each string.
313    let result_capacity = checked_capacity(string_count, size_of::<Vec<u8>>(), "FSST results")?;
314    let mut result = Vec::with_capacity(result_capacity);
315    let mut prev_end = 0;
316    let mut decoded_total = 0usize;
317    for &end in &offsets {
318        if end < prev_end || end > encoded_data.len() {
319            return Err(CodecError::Corrupt {
320                detail: "FSST string offsets are not monotonic and in range".into(),
321            });
322        }
323        let encoded_str = &encoded_data[prev_end..end];
324        let decoded = decode_string(&symbols, encoded_str)?;
325        decoded_total = checked_add(decoded_total, decoded.len(), "FSST decoded bytes")?;
326        decoded_len(decoded_total, "FSST")?;
327        result.push(decoded);
328        prev_end = end;
329    }
330    if prev_end != total_encoded {
331        return Err(CodecError::Corrupt {
332            detail: "FSST offsets do not consume encoded data".into(),
333        });
334    }
335
336    Ok(result)
337}
338
339/// Convenience: encode a single contiguous byte buffer that contains
340/// multiple strings separated by a delimiter (e.g., newlines for log data).
341pub fn encode_delimited(data: &[u8], delimiter: u8) -> Result<Vec<u8>, CodecError> {
342    decoded_len(data.len(), "FSST delimited input")?;
343    let strings: Vec<&[u8]> = data.split(|&b| b == delimiter).collect();
344    encode(&strings)
345}
346
347/// Convenience: decode and reassemble with delimiter.
348pub fn decode_delimited(data: &[u8], delimiter: u8) -> Result<Vec<u8>, CodecError> {
349    let strings = decode(data)?;
350    let mut out = Vec::new();
351    for (i, s) in strings.iter().enumerate() {
352        let separator = usize::from(i > 0);
353        let next_len = checked_add(
354            checked_add(out.len(), separator, "FSST delimited output")?,
355            s.len(),
356            "FSST delimited output",
357        )?;
358        decoded_len(next_len, "FSST delimited output")?;
359        if i > 0 {
360            out.push(delimiter);
361        }
362        out.extend_from_slice(s);
363    }
364    Ok(out)
365}
366
367// ---------------------------------------------------------------------------
368// Per-string encode / decode
369// ---------------------------------------------------------------------------
370
371fn encode_string(table: &SymbolTable, input: &[u8]) -> Result<Vec<u8>, CodecError> {
372    let capacity = checked_mul(input.len(), 2, "FSST encoded string")?;
373    decoded_len(capacity, "FSST encoded string")?;
374    let mut out = Vec::with_capacity(capacity);
375    let mut pos = 0;
376
377    while pos < input.len() {
378        // Greedy: try to match the longest symbol at current position.
379        let mut matched = false;
380        for (idx, sym) in table.symbols.iter().enumerate() {
381            if input[pos..].starts_with(sym) {
382                out.push(idx as u8);
383                pos += sym.len();
384                matched = true;
385                break;
386            }
387        }
388
389        if !matched {
390            // No symbol matches — emit escape + literal byte.
391            out.push(ESCAPE);
392            out.push(input[pos]);
393            pos += 1;
394        }
395    }
396
397    Ok(out)
398}
399
400fn decode_string(symbols: &[Vec<u8>], encoded: &[u8]) -> Result<Vec<u8>, CodecError> {
401    let expanded_capacity = checked_mul(encoded.len(), MAX_SYMBOL_LEN, "FSST decoded string")?;
402    let capacity = decoded_len(expanded_capacity, "FSST decoded string")?;
403    let mut out = Vec::with_capacity(capacity);
404    let mut pos = 0;
405
406    while pos < encoded.len() {
407        let byte = encoded[pos];
408        pos += 1;
409
410        if byte == ESCAPE {
411            // Next byte is a literal.
412            if pos >= encoded.len() {
413                return Err(CodecError::Corrupt {
414                    detail: "FSST escape at end of encoded data".into(),
415                });
416            }
417            out.push(encoded[pos]);
418            pos += 1;
419        } else {
420            // Symbol index.
421            let idx = byte as usize;
422            if idx >= symbols.len() {
423                return Err(CodecError::Corrupt {
424                    detail: format!(
425                        "FSST symbol index {idx} out of range (max {})",
426                        symbols.len()
427                    ),
428                });
429            }
430            let next_len = checked_add(out.len(), symbols[idx].len(), "FSST decoded string")?;
431            decoded_len(next_len, "FSST decoded string")?;
432            out.extend_from_slice(&symbols[idx]);
433        }
434    }
435
436    Ok(out)
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    fn encode(strings: &[&[u8]]) -> Vec<u8> {
444        super::encode(strings).expect("test FSST encode")
445    }
446
447    fn encode_delimited(data: &[u8], delimiter: u8) -> Vec<u8> {
448        super::encode_delimited(data, delimiter).expect("test FSST delimited encode")
449    }
450
451    #[test]
452    fn empty_input() {
453        let encoded = encode(&[]);
454        let decoded = decode(&encoded).unwrap();
455        assert!(decoded.is_empty());
456    }
457
458    #[test]
459    fn single_string() {
460        let strings: Vec<&[u8]> = vec![b"hello world"];
461        let encoded = encode(&strings);
462        let decoded = decode(&encoded).unwrap();
463        assert_eq!(decoded.len(), 1);
464        assert_eq!(decoded[0], b"hello world");
465    }
466
467    #[test]
468    fn multiple_strings_roundtrip() {
469        let strings: Vec<&[u8]> = vec![
470            b"us-east-1",
471            b"us-east-2",
472            b"us-west-1",
473            b"eu-west-1",
474            b"us-east-1",
475            b"us-east-1",
476        ];
477        let encoded = encode(&strings);
478        let decoded = decode(&encoded).unwrap();
479        assert_eq!(decoded.len(), strings.len());
480        for (a, b) in strings.iter().zip(decoded.iter()) {
481            assert_eq!(*a, b.as_slice());
482        }
483    }
484
485    #[test]
486    fn repetitive_log_lines() {
487        let lines: Vec<&[u8]> = (0..1000)
488            .map(|i| {
489                let s: &[u8] = match i % 5 {
490                    0 => b"2024-01-15 INFO server.handler request_id=abc method=GET status=200",
491                    1 => b"2024-01-15 INFO server.handler request_id=def method=POST status=201",
492                    2 => b"2024-01-15 WARN server.handler request_id=ghi method=GET status=404",
493                    3 => b"2024-01-15 ERROR server.handler request_id=jkl method=PUT status=500",
494                    _ => b"2024-01-15 DEBUG server.handler request_id=mno method=GET status=200",
495                };
496                s
497            })
498            .collect();
499
500        let encoded = encode(&lines);
501        let decoded = decode(&encoded).unwrap();
502        assert_eq!(decoded.len(), lines.len());
503        for (a, b) in lines.iter().zip(decoded.iter()) {
504            assert_eq!(*a, b.as_slice());
505        }
506
507        // FSST should compress repetitive logs.
508        let raw_size: usize = lines.iter().map(|s| s.len()).sum();
509        let ratio = raw_size as f64 / encoded.len() as f64;
510        assert!(
511            ratio > 1.5,
512            "FSST should compress repetitive logs >1.5x, got {ratio:.1}x"
513        );
514    }
515
516    #[test]
517    fn hostnames() {
518        let hosts: Vec<&[u8]> = vec![
519            b"prod-web-01.us-east-1.example.com",
520            b"prod-web-02.us-east-1.example.com",
521            b"prod-web-03.us-east-1.example.com",
522            b"prod-api-01.us-west-2.example.com",
523            b"prod-api-02.us-west-2.example.com",
524            b"staging-web-01.eu-west-1.example.com",
525        ];
526        let encoded = encode(&hosts);
527        let decoded = decode(&encoded).unwrap();
528        for (a, b) in hosts.iter().zip(decoded.iter()) {
529            assert_eq!(*a, b.as_slice());
530        }
531    }
532
533    #[test]
534    fn binary_data() {
535        // Binary data with no patterns — should still roundtrip (escape every byte).
536        let data: Vec<&[u8]> = vec![&[0, 1, 2, 3, 4, 255, 254, 253]];
537        let encoded = encode(&data);
538        let decoded = decode(&encoded).unwrap();
539        assert_eq!(decoded[0], data[0]);
540    }
541
542    #[test]
543    fn empty_strings() {
544        let strings: Vec<&[u8]> = vec![b"", b"hello", b"", b"world", b""];
545        let encoded = encode(&strings);
546        let decoded = decode(&encoded).unwrap();
547        assert_eq!(decoded.len(), 5);
548        assert!(decoded[0].is_empty());
549        assert_eq!(decoded[1], b"hello");
550        assert!(decoded[2].is_empty());
551    }
552
553    #[test]
554    fn delimited_roundtrip() {
555        let data = b"line one\nline two\nline three\nline one\nline two";
556        let encoded = encode_delimited(data, b'\n');
557        let decoded = decode_delimited(&encoded, b'\n').unwrap();
558        assert_eq!(decoded, data);
559    }
560
561    #[test]
562    fn compression_ratio_structured_logs() {
563        let mut lines: Vec<Vec<u8>> = Vec::new();
564        for i in 0..5000 {
565            let line = format!(
566                "2024-01-15T10:30:{:02}.000Z INFO server.handler request_id={} method=GET path=/api/v1/metrics status=200 duration_ms={}",
567                i % 60,
568                10000 + i,
569                i * 3 + 1
570            );
571            lines.push(line.into_bytes());
572        }
573        let refs: Vec<&[u8]> = lines.iter().map(|l| l.as_slice()).collect();
574
575        let encoded = encode(&refs);
576        let decoded = decode(&encoded).unwrap();
577        assert_eq!(decoded.len(), lines.len());
578
579        let raw_size: usize = lines.iter().map(|s| s.len()).sum();
580        let ratio = raw_size as f64 / encoded.len() as f64;
581        assert!(
582            ratio > 1.5,
583            "FSST should compress structured logs >1.5x, got {ratio:.1}x"
584        );
585    }
586
587    #[test]
588    fn malformed_sections_and_trailing_bytes_are_rejected() {
589        assert!(decode(&[]).is_err());
590        assert!(decode(&[1]).is_err());
591        let mut encoded = encode(&[b"hello".as_slice()]);
592        encoded.push(0);
593        assert!(matches!(decode(&encoded), Err(CodecError::Corrupt { .. })));
594        let mut invalid_offset = encode(&[b"hello".as_slice()]);
595        let symbol_count = usize::from(u16::from_le_bytes([invalid_offset[0], invalid_offset[1]]));
596        let mut cursor = 2;
597        for _ in 0..symbol_count {
598            cursor += 1 + usize::from(invalid_offset[cursor]);
599        }
600        let offsets_start = cursor + 8;
601        // The encoder's single-string final offset is the encoded-data length;
602        // zero makes the remaining encoded data unreachable.
603        invalid_offset[offsets_start..offsets_start + 4].copy_from_slice(&0u32.to_le_bytes());
604        assert!(matches!(
605            decode(&invalid_offset),
606            Err(CodecError::Corrupt { .. })
607        ));
608    }
609
610    #[test]
611    fn large_dataset() {
612        let mut strings: Vec<Vec<u8>> = Vec::new();
613        for i in 0..10_000 {
614            strings.push(format!("key-{}-value-{}", i % 100, i % 50).into_bytes());
615        }
616        let refs: Vec<&[u8]> = strings.iter().map(|s| s.as_slice()).collect();
617        let encoded = encode(&refs);
618        let decoded = decode(&encoded).unwrap();
619        assert_eq!(decoded.len(), strings.len());
620        for (a, b) in strings.iter().zip(decoded.iter()) {
621            assert_eq!(a.as_slice(), b.as_slice());
622        }
623    }
624}