Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions encodings/fsst/src/compute/like.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ mod tests {
use vortex_array::scalar_fn::fns::like::Like;
use vortex_array::scalar_fn::fns::like::LikeKernel;
use vortex_array::scalar_fn::fns::like::LikeOptions;
use vortex_error::VortexExpect;
use vortex_error::VortexResult;
use vortex_session::VortexSession;

Expand Down Expand Up @@ -282,6 +283,42 @@ mod tests {
Ok(())
}

/// `%suffix` must be evaluated by the kernel, not handed back for
/// decompression. Asserting the result is `Some` is what distinguishes
/// pushdown from the fallback path — the boolean answer is the same either way.
#[test]
fn test_like_kernel_pushes_down_suffix() -> VortexResult<()> {
let fsst = make_fsst(
&[Some("abc"), Some("xabc"), Some("abcx")],
Nullability::NonNullable,
);
let mut ctx = SESSION.create_execution_ctx();
let fsst_v = fsst.as_view();

let pattern = ConstantArray::new("%abc", fsst.len()).into_array();
let result =
<FSST as LikeKernel>::like(fsst_v, &pattern, LikeOptions::default(), &mut ctx)?
.vortex_expect("suffix pattern must be pushed down, not fall back");
let expected = BoolArray::from_iter([true, true, false]);
assert_arrays_eq!(&result, &expected, &mut ctx);

// Negated form goes through the same matcher.
let result = <FSST as LikeKernel>::like(
fsst_v,
&pattern,
LikeOptions {
negated: true,
case_insensitive: false,
},
&mut ctx,
)?
.vortex_expect("negated suffix pattern must be pushed down");
let expected = BoolArray::from_iter([false, false, true]);
assert_arrays_eq!(&result, &expected, &mut ctx);

Ok(())
}

/// Patterns we can't handle should return `None` (fall back).
#[test]
fn test_like_kernel_falls_back_for_complex_pattern() -> VortexResult<()> {
Expand All @@ -304,11 +341,11 @@ mod tests {
let result = <FSST as LikeKernel>::like(fsst_v, &pattern, opts, &mut ctx)?;
assert!(result.is_none(), "ilike should fall back");

// Suffix patterns are still unsupported, even when the suffix is an escaped literal.
let pattern = ConstantArray::new(r"%\%", fsst.len()).into_array();
// A `%` in the middle is none of prefix, contains or suffix.
let pattern = ConstantArray::new("a%b", fsst.len()).into_array();
let result =
<FSST as LikeKernel>::like(fsst_v, &pattern, LikeOptions::default(), &mut ctx)?;
assert!(result.is_none(), "escaped suffix pattern should fall back");
assert!(result.is_none(), "mid-pattern % should fall back");

Ok(())
}
Expand Down
163 changes: 163 additions & 0 deletions encodings/fsst/src/dfa/flat_suffix.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors

//! Flat `u8` transition table DFA for suffix matching (`LIKE '%suffix'`).
//!
//! This is the Forward DFA of the two approaches sketched in the module docs: the
//! same KMP table the contains DFA uses, but with a **non-sticky** accept state,
//! so the walk continues past a match and the answer is "were we in the accept
//! state when the codes ran out".
//!
//! ## Why accept cannot be sticky
//!
//! Contains asks "did we ever reach accept", so it can freeze there and stop.
//! Suffix asks "are we at accept at the end", so a match that is later extended
//! must be given up:
//!
//! ```text
//! Suffix "ab", input "abc"
//! 'a' -> 1, 'b' -> 2 (accept), 'c' -> 0 final state 0 => no match
//! Suffix "ab", input "cab"
//! 'c' -> 0, 'a' -> 1, 'b' -> 2 (accept) final state 2 => match
//! ```
//!
//! A sticky accept state would report a match for both. That is also why this
//! file cannot reuse [`super::build_symbol_transitions`], which short-circuits
//! once a symbol's bytes reach accept.

use fsst::Symbol;
use vortex_error::VortexExpect;
use vortex_error::VortexResult;
use vortex_error::vortex_bail;

use super::build_fused_table;
use super::kmp_failure_table;

/// Flat `u8` transition table DFA for suffix matching on FSST codes.
///
/// States `0..suffix_len` track match progress and `suffix_len` is the accept
/// state; unlike the prefix and contains DFAs it is not sticky. The escape code
/// maps to a sentinel state and the next literal byte is looked up in a separate
/// byte-level table.
pub(crate) struct FlatSuffixDfa {
/// `transitions[state * 256 + code]` -> next state.
transitions: Vec<u8>,
/// `escape_transitions[state * 256 + byte]` -> next state for escaped bytes.
escape_transitions: Vec<u8>,
accept_state: u8,
sentinel: u8,
}

impl FlatSuffixDfa {
/// Maximum suffix length: need accept + sentinel to fit in u8.
pub(crate) const MAX_SUFFIX_LEN: usize = u8::MAX as usize - 1;

pub(crate) fn new(
symbols: &[Symbol],
symbol_lengths: &[u8],
suffix: &[u8],
) -> VortexResult<Self> {
if suffix.len() > Self::MAX_SUFFIX_LEN {
vortex_bail!(
"suffix length {} exceeds maximum {} for flat suffix DFA",
suffix.len(),
Self::MAX_SUFFIX_LEN
);
}

let accept_state = u8::try_from(suffix.len())
.vortex_expect("FlatSuffixDfa: accept state must fit into u8");
let n_states = accept_state + 1;
let sentinel = n_states;

let byte_table = resumable_kmp_byte_transitions(suffix);
let sym_trans =
resumable_symbol_transitions(symbols, symbol_lengths, &byte_table, n_states);
let transitions = build_fused_table(&sym_trans, symbols.len(), n_states, |_| sentinel, 0);

Ok(Self {
transitions,
escape_transitions: byte_table,
accept_state,
sentinel,
})
}

pub(crate) fn matches(&self, codes: &[u8]) -> bool {
let mut state = 0u8;
let mut pos = 0;
while pos < codes.len() {
let code = codes[pos];
pos += 1;
let next = self.transitions[usize::from(state) * 256 + usize::from(code)];
if next == self.sentinel {
// A trailing escape code with no byte after it is a malformed
// stream; treat it as no match rather than reading past the end.
if pos >= codes.len() {
return false;
}
let b = codes[pos];
pos += 1;
state = self.escape_transitions[usize::from(state) * 256 + usize::from(b)];
} else {
state = next;
}
}
state == self.accept_state
}
}

/// KMP `(state × byte) → state` table whose accept state keeps transitioning.
///
/// Identical to [`super::kmp_byte_transitions`] except that the accept state is
/// walked through the failure function like any other state, which is what lets
/// a match be invalidated by trailing bytes.
fn resumable_kmp_byte_transitions(needle: &[u8]) -> Vec<u8> {
let n_states = u8::try_from(needle.len() + 1)
.vortex_expect("resumable_kmp_byte_transitions: must have needle.len() <= 254");
let failure = kmp_failure_table(needle);

let mut table = vec![0u8; usize::from(n_states) * 256];
for state in 0..n_states {
for byte in 0..256usize {
let mut s = state;
loop {
// At accept there is no `needle[s]` to compare, so fall back
// first; every other state compares before falling back.
if usize::from(s) < needle.len() && byte == usize::from(needle[usize::from(s)]) {
s += 1;
break;
}
if s == 0 {
break;
}
s = failure[usize::from(s) - 1];
}
table[usize::from(state) * 256 + byte] = s;
}
}
table
}

/// Per-symbol transitions that do not short-circuit at the accept state.
fn resumable_symbol_transitions(
symbols: &[Symbol],
symbol_lengths: &[u8],
byte_table: &[u8],
n_states: u8,
) -> Vec<u8> {
let n_symbols = symbols.len();
let mut sym_trans = vec![0u8; usize::from(n_states) * n_symbols];
for state in 0..n_states {
for code in 0..n_symbols {
let sym = symbols[code].to_u64().to_le_bytes();
let sym_len = usize::from(symbol_lengths[code]);
let mut s = state;
for &b in &sym[..sym_len] {
s = byte_table[usize::from(s) * 256 + usize::from(b)];
}
sym_trans[usize::from(state) * n_symbols + code] = s;
}
}
sym_trans
}
77 changes: 68 additions & 9 deletions encodings/fsst/src/dfa/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,11 @@
//! returns `None` and the caller must fall back to ordinary decompression-based
//! LIKE evaluation.
//!
//! TODO(joe): suffix (`'%suffix'`) pushdown. Two approaches:
//! - **Forward DFA**: use a non-sticky accept state with KMP fallback transitions,
//! check `state == accept` after processing all codes. Branchless and vectorizable.
//! - **Backward scan**: walk the compressed code stream in reverse, comparing symbol
//! bytes from the end. Simpler, no DFA construction, but requires reverse parsing
//! of the FSST escape mechanism.
//! Suffix (`'%suffix'`) pushdown takes the Forward DFA route sketched in the
//! original TODO: a non-sticky accept state with KMP fallback transitions, checking
//! `state == accept` after processing all codes. The alternative, walking the code
//! stream in reverse, needs reverse parsing of the FSST escape mechanism and was
//! not pursued.
//!
//! ## Background: FSST Encoding
//!
Expand Down Expand Up @@ -122,13 +121,15 @@
//! not use FSST pushdown and must be evaluated through the fallback path.

mod flat_contains;
mod flat_suffix;
mod prefix;
#[cfg(test)]
mod tests;

use std::borrow::Cow;

use flat_contains::FlatContainsDfa;
use flat_suffix::FlatSuffixDfa;
use fsst::ESCAPE_CODE;
use fsst::Symbol;
use prefix::FlatPrefixDfa;
Expand All @@ -154,14 +155,15 @@ enum MatcherInner {
MatchAll,
Prefix(FlatPrefixDfa),
Contains(FlatContainsDfa),
Suffix(FlatSuffixDfa),
}

impl FsstMatcher {
/// Try to build a matcher for the given LIKE pattern.
///
/// Returns `Ok(None)` if the pattern shape is not supported for pushdown
/// (e.g. `_` wildcards, multiple non-bookend `%`, `prefix%` longer than
/// 253 bytes, or `%needle%` longer than 254 bytes).
/// 253 bytes, or `%needle%`/`%suffix` longer than 254 bytes).
pub(crate) fn try_new(
symbols: &[Symbol],
symbol_lengths: &[u8],
Expand All @@ -172,7 +174,9 @@ impl FsstMatcher {
};

let inner = match like_kind {
LikeKind::Prefix(pattern) | LikeKind::Contains(pattern) if pattern.is_empty() => {
LikeKind::Prefix(pattern) | LikeKind::Contains(pattern) | LikeKind::Suffix(pattern)
if pattern.is_empty() =>
{
MatcherInner::MatchAll
}
LikeKind::Prefix(prefix) => {
Expand All @@ -195,6 +199,16 @@ impl FsstMatcher {
needle.as_ref(),
)?)
}
LikeKind::Suffix(suffix) => {
if suffix.len() > FlatSuffixDfa::MAX_SUFFIX_LEN {
return Ok(None);
}
MatcherInner::Suffix(FlatSuffixDfa::new(
symbols,
symbol_lengths,
suffix.as_ref(),
)?)
}
};

Ok(Some(Self { inner }))
Expand All @@ -206,6 +220,7 @@ impl FsstMatcher {
MatcherInner::MatchAll => true,
MatcherInner::Prefix(dfa) => dfa.matches(codes),
MatcherInner::Contains(dfa) => dfa.matches(codes),
MatcherInner::Suffix(dfa) => dfa.matches(codes),
}
}
}
Expand All @@ -216,11 +231,15 @@ enum LikeKind<'a> {
Prefix(Cow<'a, [u8]>),
/// `%needle%`
Contains(Cow<'a, [u8]>),
/// `%suffix`
Suffix(Cow<'a, [u8]>),
}

impl<'a> LikeKind<'a> {
fn parse(pattern: &'a [u8]) -> Option<Self> {
Self::parse_prefix(pattern).or_else(|| Self::parse_contains(pattern))
Self::parse_prefix(pattern)
.or_else(|| Self::parse_contains(pattern))
.or_else(|| Self::parse_suffix(pattern))
}

fn parse_prefix(pattern: &'a [u8]) -> Option<Self> {
Expand All @@ -235,6 +254,46 @@ impl<'a> LikeKind<'a> {
Self::parse_literal_until_final_percent(pattern, 1).map(LikeKind::Contains)
}

fn parse_suffix(pattern: &'a [u8]) -> Option<Self> {
if !pattern.starts_with(b"%") {
return None;
}

Self::parse_literal_to_end(pattern, 1).map(LikeKind::Suffix)
}

/// Parse `pattern[literal_start..]` as a literal running to the end of the
/// pattern. Returns `None` if `_` or `%` is encountered, since either means
/// the tail is not a plain literal.
fn parse_literal_to_end(pattern: &'a [u8], literal_start: usize) -> Option<Cow<'a, [u8]>> {
let mut literal: Option<Vec<u8>> = None;
let mut idx = literal_start;
while idx < pattern.len() {
match pattern[idx] {
b'\\' => {
// Trailing `\` is treated as a literal backslash.
let escaped = pattern.get(idx + 1).copied().unwrap_or(b'\\');
literal
.get_or_insert_with(|| pattern[literal_start..idx].to_vec())
.push(escaped);
idx = (idx + 2).min(pattern.len());
}
b'%' | b'_' => return None,
byte => {
// No-op on the borrowed path; only push once we've started copying.
if let Some(literal) = &mut literal {
literal.push(byte);
}
idx += 1;
}
}
}
Some(match literal {
Some(buf) => Cow::Owned(buf),
None => Cow::Borrowed(&pattern[literal_start..]),
})
}

/// Parse `pattern[literal_start..]` as a literal terminated by a single
/// trailing `%`. Returns `None` if `_` or a non-final `%` is encountered.
///
Expand Down
Loading