airborne12 commented on code in PR #67538:
URL: https://github.com/apache/doris/pull/67538#discussion_r3988883883
##########
fe/fe-core/src/main/java/org/apache/doris/indexpolicy/NGramTokenizerValidator.java:
##########
@@ -103,4 +145,80 @@ protected void validateSpecific(Map<String, String> props)
throws DdlException {
}
}
}
+
+ /**
+ * Validates the gram-family (auto/sparse/dense) parameters: the value
domain of mode itself, the
+ * defaults and the ordering of min/max_gram, the value domains of
density/lower_case, and the
+ * mutual exclusion of the token_chars family with mode.
+ * An empty mode string (which the allow-list stage does not catch) is
rejected here too, for not
+ * belonging to VALID_MODES.
+ *
+ * <p>Every value domain mirrors BE's
`gram_scheme.cpp::GramScheme::from_properties` entry by entry:
+ * min_gram in [1, 64], max_gram in [1, 256], density in [0.001, 1].
+ * FE rejects out-of-range values up front, so a DDL cannot pass only for
BE to report
+ * InvalidArgument when it parses the gram scheme.
+ */
+ private void validateGramMode(Map<String, String> props, String mode)
throws DdlException {
+ if (!VALID_MODES.contains(mode)) {
+ throw new DdlException("ngram tokenizer mode must be one of " +
VALID_MODES
+ + ", got: '" + mode + "'" + (mode.isEmpty() ? " (empty)" :
""));
+ }
+ int minGram = parseIntInRange(props, "min_gram", 3,
MIN_GRAM_LOWER_BOUND, MIN_GRAM_UPPER_BOUND);
+ int maxGram = parseIntInRange(props, "max_gram", 16,
MAX_GRAM_LOWER_BOUND, MAX_GRAM_UPPER_BOUND);
Review Comment:
Confirmed and fixed in d2615d864129c3656abf08e541a84c0563c33845.
Reproduced at SQL level on a live cluster: a tokenizer created with only
`type=ngram, mode=sparse, min_gram=5` passes FE DDL and the table is created,
then the first load fails on the backend with `[E-6011] ... max_gram(4) <
min_gram(5)` and the table stays at 0 rows. So the damage is a table that looks
created and cannot be written, which matches the description.
The validator now substitutes the same defaults `GramScheme` applies --
`min_gram` 3, `max_gram` 4, as named constants rather than literals -- so a DDL
that cannot be written is refused where it is written. Test:
`GramDdlValidationTest#testOmittedMaxGramIsValidatedAgainstTheDefaultBackendApplies`:
the omitted-`max_gram` case is rejected naming `min_gram (5) must be <=
max_gram (4)`, while a spelled-out `max_gram=8` and a `min_gram=3` under the
default both stay accepted.
##########
be/src/storage/index/inverted/gram/regex_ast.cpp:
##########
@@ -0,0 +1,800 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#include "storage/index/inverted/gram/regex_ast.h"
+
+#include <algorithm>
+#include <cctype>
+#include <cstdint>
+
+namespace doris::segment_v2::gram {
+
+// The BE storage target enables CMake unity builds (several .cpp files are
compiled together,
+// see UNITY_BUILD_BATCH_SIZE in be/src/storage/CMakeLists.txt), so every
anonymous namespace in
+// a batch is merged into one translation unit. A bare anonymous namespace
then redefines any
+// symbol whose name another file of the same batch happens to reuse (even in
a different .cpp),
+// and the batching changes as files are added to or removed from the
directory, so "this batch
+// only holds these files" cannot be assumed for long. Hence the extra named
namespace private
+// to this file, which isolates this file's anonymous namespace; the symbols
inside it still
+// have internal linkage (anonymous-namespace semantics are unaffected by a
named enclosing
+// namespace).
+namespace regex_ast_detail {
+
+namespace {
+
+// Maximum recursion nesting depth of `(...)` groups: every extra group level
adds one more
+// recursion through the parse_alt/parse_cat/parse_atom call chain. A
malformed (or maliciously
+// crafted) regex can drive that chain very deep with a pile of nested
parentheses and blow the
+// stack; this repository has already seen a stack overflow from deep
recursion (CIR-21633), so
+// there is a hard cap here that errors out instead of recursing further.
+constexpr int kMaxNestingDepth = 64;
+
+// The parser derives conservative literal constraints for the scalar regex
engines.
+// Unsupported syntax fails parsing so the caller can skip gram filtering.
+
+// Infer the byte length of a UTF-8 sequence from its lead byte; an illegal
lead byte counts as a
+// single byte.
+int utf8_len(unsigned char c) {
+ if (c < 0x80) {
+ return 1;
+ }
+ if ((c >> 5) == 0x6) {
+ return 2;
+ }
+ if ((c >> 4) == 0xE) {
+ return 3;
+ }
+ if ((c >> 3) == 0x1E) {
+ return 4;
+ }
+ return 1; // illegal lead byte: treat it as a single byte
+}
+
+// The largest legal Unicode code point. Anything above it can only be a fake
code point minted
+// by decode_one_cp for an ill-formed byte, and must never reach encode_cp:
the four-byte sequence
+// encode_cp would produce encodes a value above U+10FFFF, so it is a byte
string no encoder can
+// emit and no index can hold, and demanding it as a gram would filter every
row away.
+constexpr uint32_t kMaxCodePoint = 0x10FFFF;
+
+// Decode the code point starting at s[0]; s must not be empty. A well-formed
UTF-8 sequence
+// yields its code point and its byte length; any ill-formed byte (an illegal
lead byte, a
+// truncated sequence or a bad continuation byte) yields the fake code point
0x110000+byte (still
+// < 2^21, so it cannot collide with a legal one) and consumes exactly one
byte.
+//
+// *consumed is what keeps a caller's cursor in sync with the decoder.
Advancing by the length
+// guessed from the lead byte instead would swallow the bytes following an
ill-formed sequence --
+// regex metacharacters among them -- and silently compile a different pattern
than the engine
+// sees.
+uint32_t decode_one_cp(std::string_view s, size_t* consumed) {
+ const auto c = static_cast<unsigned char>(s[0]);
+ const int l = utf8_len(c);
+ *consumed = 1;
+ if (l == 1) {
+ return c < 0x80 ? c : 0x110000U + c;
+ }
+ if (static_cast<size_t>(l) > s.size()) {
+ return 0x110000U + c;
+ }
+ uint32_t v = 0;
+ if (l == 2) {
+ v = c & 0x1FU;
+ } else if (l == 3) {
+ v = c & 0x0FU;
+ } else {
+ v = c & 0x07U;
+ }
+ for (int k = 1; k < l; k++) {
+ const auto cc = static_cast<unsigned char>(s[k]);
+ if ((cc & 0xC0) != 0x80) {
+ return 0x110000U + c;
+ }
+ v = (v << 6) | (cc & 0x3FU);
+ }
+ *consumed = static_cast<size_t>(l);
Review Comment:
Confirmed and fixed in b873d817752034eebd3e9ecd233c7e6df82f69c7.
Reproduced at compiler level: `abc<C0 AF>def` compiled to exactly what
`abc/def` compiles to -- `("/de" & "abc" & "bc/" & "c/d" & "def")` -- while a
row holding the original bytes stores only `"xxa" "xab" "abc" "def" "efy"
"fyy"`, none of them slash-bearing. The index therefore answers empty and the
intersection drops a row the byte-oriented fallback would have matched.
The decoder now returns a non-code-point sentinel (`0x110000 + lead byte`)
for overlong, surrogate and out-of-range sequences, so those bytes stay
separators exactly as every other invalid byte does, which is also what the
extractor does with them on the write side. `*consumed` stays 1 on that path,
so the cursor still steps one byte and cannot swallow a following metacharacter.
Test:
`RegexGramCompilerTest.IllFormedUtf8IsNotDecodedIntoTheCodePointItSpells`,
covering the overlong two- and three-byte forms, a surrogate half and a value
above U+10FFFF. To be explicit about what it does not cover: it asserts at
compiler level that the ill-formed pattern no longer compiles to the same query
as `abc/def`, rather than driving a query through the Boost fallback. Getting
those raw bytes into a row and back out through the regression harness is not
something I could make deterministic, and the compiled query is where the
defect lives -- the fallback only makes it observable.
##########
be/src/storage/index/inverted/gram/regex_gram_compiler.cpp:
##########
@@ -0,0 +1,720 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#include "storage/index/inverted/gram/regex_gram_compiler.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <memory>
+#include <set>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "common/logging.h"
+#include "storage/index/inverted/gram/regex_ast.h"
+
+namespace doris::segment_v2::gram {
+
+// Storage is a unity build: other .cpp files in this directory also define
file-level helpers
+// named utf8_len / codepoint_len, so this file's helpers live in a
file-specific namespace
+// wrapped in an anonymous one.
+namespace regex_gram_compiler_detail {
+namespace {
+
+// An exact set whose strings are all already >= n yields useful grams more
often, so it is
+// demoted past 4 entries; together with kMaxExact (the cap that demotes
regardless of length)
+// these are the prototype simplify's two thresholds.
+constexpr size_t kMaxLongExact = 4;
+// Recursion depth cap for analyze. parse_regex only bounds group nesting (64
levels), while
+// stacked quantifiers such as `a++++...` build an equally deep PLUS/REPEAT
chain; past the cap
+// we degrade to info_any_match (no constraint at all, conservative and safe)
so a user-supplied
+// pattern cannot blow the stack.
+constexpr int kMaxAnalyzeDepth = 200;
+// Maximum number of copies a REPEAT is unrolled into: x{m,..} is expanded
exactly min(m, 4)
+// times and then handled as a plus.
+constexpr int kMaxRepeatUnroll = 4;
+
+// Byte length (1/2/3/4) of the UTF-8 sequence starting with lead byte c; an
illegal lead byte
+// counts as 1.
+inline int utf8_len(unsigned char c) {
+ if (c < 0x80) {
+ return 1;
+ }
+ if ((c >> 5) == 0x6) {
+ return 2;
+ }
+ if ((c >> 4) == 0xE) {
+ return 3;
+ }
+ if ((c >> 3) == 0x1E) {
+ return 4;
+ }
+ return 1;
+}
+
+// Byte length of one valid UTF-8 code point starting at p; returns 1 for a
truncated sequence or
+// a stray continuation byte. Exactly the rule GramExtractor uses: on the
index side a non-ASCII
+// code point is a whole 1-gram.
+inline size_t codepoint_len(const char* p, size_t remain) {
+ int l = utf8_len((unsigned char)p[0]);
+ if (l == 1 || (size_t)l > remain) {
+ return 1;
+ }
+ for (int k = 1; k < l; k++) {
+ if (((unsigned char)p[k] & 0xC0) != 0x80) {
+ return 1;
+ }
+ }
+ return l;
+}
+
+// The leading <= k bytes of s, always stopping on a code point boundary.
Cutting a multi-byte
+// code point in half would forge "fake grams" that do not exist in the index
and cause false
+// negatives, so we would rather keep a few bytes fewer.
+std::string head_units(const std::string& s, size_t k) {
+ size_t i = 0;
+ while (i < s.size()) {
+ size_t l = codepoint_len(s.data() + i, s.size() - i);
+ if (i + l > k) {
+ break;
+ }
+ i += l;
+ }
+ return s.substr(0, i);
+}
+
+// The trailing <= k bytes of s, always starting on a code point boundary
(same reason as
+// head_units).
+std::string tail_units(const std::string& s, size_t k) {
+ if (s.size() <= k) {
+ return s;
+ }
+ size_t i = 0;
+ while (i < s.size() && s.size() - i > k) {
+ i += codepoint_len(s.data() + i, s.size() - i);
+ }
+ return s.substr(i);
+}
+
+// Strict UTF-8 well-formedness test, the guard in front of "a literal becomes
mandatory grams".
+//
+// The invariant it protects is the one the whole feature rests on: every gram
the query side
+// derives from a literal must be a gram the index side could have produced
from a row that
+// really matches. GramExtractor splits a row position-dependently -- an ASCII
run is windowed,
+// and every byte >= 0x80 is consumed as one whole code point -- so the same
bytes are split
+// differently depending on whether they start on a code point boundary. A
literal that is not
+// itself well-formed UTF-8 therefore yields grams that need not exist in a
matching row, and
+// requiring them prunes the row away.
+//
+// Rejected: a stray continuation byte (0x80-0xBF) as a lead byte, an illegal
lead byte
+// (0xF8-0xFF), a sequence that runs past the end of s, a bad continuation
byte, an overlong
+// encoding, a surrogate (U+D800-U+DFFF), and anything above U+10FFFF -- the
last of which also
+// catches whatever fake code point may still slip past the degradations in
regex_ast.cpp.
+bool is_well_formed_utf8(const std::string& s) {
+ size_t i = 0;
+ while (i < s.size()) {
+ const auto c = static_cast<unsigned char>(s[i]);
+ if (c < 0x80) {
+ i++;
+ continue;
+ }
+ size_t len = 0;
+ uint32_t cp = 0;
+ uint32_t lowest = 0; // smallest code point this length may legally
encode
+ if ((c & 0xE0) == 0xC0) {
+ len = 2;
+ cp = c & 0x1FU;
+ lowest = 0x80;
+ } else if ((c & 0xF0) == 0xE0) {
+ len = 3;
+ cp = c & 0x0FU;
+ lowest = 0x800;
+ } else if ((c & 0xF8) == 0xF0) {
+ len = 4;
+ cp = c & 0x07U;
+ lowest = 0x10000;
+ } else {
+ return false; // continuation byte used as a lead, or 0xF8-0xFF
+ }
+ if (i + len > s.size()) {
+ return false; // the sequence is not fully contained in s
+ }
+ for (size_t k = 1; k < len; k++) {
+ const auto cc = static_cast<unsigned char>(s[i + k]);
+ if ((cc & 0xC0) != 0x80) {
+ return false;
+ }
+ cp = (cp << 6) | (cc & 0x3FU);
+ }
+ if (cp < lowest || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) {
+ return false;
+ }
+ i += len;
+ }
+ return true;
+}
+
+// Cartesian-product concatenation; sets *too_big and returns an empty set
once the result would
+// exceed kMaxSet (the caller then takes the demotion path).
+std::set<std::string> cross(const std::set<std::string>& a, const
std::set<std::string>& b,
+ bool* too_big) {
+ std::set<std::string> r;
+ *too_big = a.size() * b.size() > RegexGramCompiler::kMaxSet;
+ if (*too_big) {
+ return r;
+ }
+ for (const auto& x : a) {
+ for (const auto& y : b) {
+ r.insert(x + y);
+ }
+ }
+ return r;
+}
+
+std::set<std::string> uni(const std::set<std::string>& a, const
std::set<std::string>& b) {
+ std::set<std::string> r = a;
+ r.insert(b.begin(), b.end());
+ return r;
+}
+
+// Byte length of the shortest string in the set; an empty set counts as 0.
+size_t min_str_len(const std::set<std::string>& s) {
+ size_t m = SIZE_MAX;
+ for (const auto& x : s) {
+ m = std::min(m, x.size());
+ }
+ return m == SIZE_MAX ? 0 : m;
+}
+
+// Cox's five-tuple: a finite approximation of the string set a regex subtree
can match.
+// can_empty the subtree can match the empty string
+// has_exact the match set is fully enumerated by exact (prefix/suffix are
then meaningless)
+// exact the fully enumerated set of matching strings
+// prefix the possible beginnings of every matching string (nothing
outside the set can
+// start a match)
+// suffix the possible endings of every matching string
+// match the gram condition already known to hold inside every matching
string
+// Invariant: exact is non-empty when has_exact; otherwise prefix and suffix
are both non-empty
+// ("" meaning no constraint).
+struct Info {
+ bool can_empty = false;
+ bool has_exact = false;
+ std::set<std::string> exact;
+ std::set<std::string> prefix;
+ std::set<std::string> suffix;
+ GramQuery match; // defaults to ALL
+};
+
+// Matches the empty string only.
+Info info_empty() {
+ Info i;
+ i.can_empty = true;
+ i.has_exact = true;
+ i.exact = {""};
+ return i;
+}
+
+// Matches exactly one unknown character (`.`, a big class, a non-indexable
literal with NUL).
+Info info_any_char() {
+ Info i;
+ i.prefix = {""};
+ i.suffix = {""};
+ return i;
+}
+
+// Matches any string (`x*`, or a subtree we cannot reason about): imposes no
constraint.
+Info info_any_match() {
+ Info i;
+ i.can_empty = true;
+ i.prefix = {""};
+ i.suffix = {""};
+ return i;
+}
+
+const std::set<std::string>& pre(const Info& x) {
+ return x.has_exact ? x.exact : x.prefix;
+}
+
+const std::set<std::string>& suf(const Info& x) {
+ return x.has_exact ? x.exact : x.suffix;
+}
+
+// The body of the Cox derivation. Every place that can produce grams goes
through q_of_string /
+// q_of_set, both of which return ALL when no gram is available, which
guarantees "rather not
+// filter at all than filter a match away".
+class CoxAnalyzer {
+public:
+ explicit CoxAnalyzer(GramExtractor& extractor)
+ : _extractor(extractor), _scheme(extractor.scheme()) {}
+
+ // Whole tree -> gram query.
+ GramQuery compile(const RegexNode* root) {
+ Info info = _analyze(root, 0);
+ GramQuery m = std::move(info.match);
+ if (info.has_exact) {
+ m = GramQuery::and_(std::move(m), q_of_set(info.exact));
+ } else {
+ m = GramQuery::and_(std::move(m), q_of_set(info.prefix));
+ m = GramQuery::and_(std::move(m), q_of_set(info.suffix));
+ }
+ return m;
+ }
+
+ // AND of every gram of the literal s. Returns ALL when s yields no gram
(too short, or no
+ // CDC boundary).
+ GramQuery q_of_string(const std::string& s) {
+ // The index side never produces a gram containing NUL, and neither
may we (Ruling R9).
+ // analyze already treats a literal node holding NUL as anyChar; this
is the fallback for
+ // other entry points such as compile_like.
+ //
+ // The UTF-8 test guards the same kind of mismatch for multi-byte
content: this function
+ // is the single place where a literal turns into mandatory grams, so
an ill-formed
+ // literal -- a LIKE segment that starts in the middle of a code
point, raw illegal bytes
+ // in either kind of pattern -- has to degrade here rather than demand
grams that a
+ // matching row may not hold.
+ if (s.find('\0') != std::string::npos || !is_well_formed_utf8(s)) {
+ return GramQuery::all();
+ }
+ std::vector<std::string> g;
+ _extractor.grams_of_literal(s, &g);
+ if (g.empty()) {
+ return GramQuery::all();
+ }
+ GramQuery q;
+ q.op = GramQuery::Op::AND;
+ q.grams = std::move(g);
+ std::sort(q.grams.begin(), q.grams.end());
+ q.grams.erase(std::unique(q.grams.begin(), q.grams.end()),
q.grams.end());
+ return q;
+ }
+
+ // Any one string of the set suffices -> OR. An empty set means "nothing
can match", hence
+ // NONE.
+ GramQuery q_of_set(const std::set<std::string>& ss) {
+ if (ss.empty()) {
+ return GramQuery::none();
+ }
+ GramQuery r = GramQuery::none();
+ for (const auto& s : ss) {
+ r = GramQuery::or_(std::move(r), q_of_string(s));
+ }
+ return r;
+ }
+
+private:
+ // How much of prefix/suffix to keep: a SPARSE gram is at most max_len
long, so a whole gram
+ // has to fit; DENSE has fixed length n, so n-1 is enough (anything longer
only repeats grams
+ // already folded into match).
+ size_t _keep() const {
+ if (_scheme.mode == GramMode::SPARSE) {
+ return _scheme.max_len;
+ }
+ return _scheme.min_len >= 1 ? _scheme.min_len - 1 : 0;
+ }
+
+ // Whether every string in the set is long enough (>= n). Folding the
whole set into match is
+ // only worth it when they all are: if a single string yields no gram, the
OR collapses to
+ // ALL and the fold constrains nothing.
+ bool _all_long(const std::set<std::string>& s) const {
+ return std::ranges::all_of(s,
+ [this](const auto& x) { return x.size() >=
_scheme.min_len; });
+ }
+
+ // With scheme.lower_case the index side already folds ASCII letters to
lower case, so
+ // literals must fold the same way. The whole pattern string must not be
lowercased -- that
+ // would break the case-sensitive escapes `\E \B \W \D \S \P \A` -- so
folding happens only
+ // on AST leaves (LIT / CLASS elements).
+ std::string _fold(const std::string& s) const {
+ if (!_scheme.lower_case) {
+ return s;
+ }
+ std::string r = s;
+ for (auto& ch : r) {
+ if (ch >= 'A' && ch <= 'Z') {
+ ch = static_cast<char>(ch - 'A' + 'a');
+ }
+ }
+ return r;
+ }
+
+ // Fold the set's grams into match (only meaningful when every string is
>= n, otherwise it
+ // carries no information), then trim the strings to keep bytes; if the
set still exceeds
+ // kMaxSet after trimming, keep shrinking keep until it is small enough or
trimmed to empty.
+ void _trim_set(std::set<std::string>* s, GramQuery* match, bool is_suffix)
{
+ if (s->empty()) {
+ return;
+ }
+ if (_all_long(*s)) {
+ *match = GramQuery::and_(std::move(*match), q_of_set(*s));
+ }
+ size_t keep = _keep();
+ for (;;) {
+ std::set<std::string> t;
+ for (const auto& x : *s) {
+ t.insert(is_suffix ? tail_units(x, keep) : head_units(x,
keep));
+ }
+ *s = std::move(t);
+ if (s->size() <= RegexGramCompiler::kMaxSet || keep == 0) {
+ break;
+ }
+ keep--;
+ }
+ }
+
+ // Demote exact to prefix/suffix: the full enumeration can no longer be
maintained, but
+ // "every matching string starts with one of the exact strings and ends
with one of them"
+ // still holds; fold the grams into match first so that information is not
lost.
+ void _demote(Info* x) {
+ if (!x->has_exact) {
+ return;
+ }
+ if (_all_long(x->exact)) {
+ x->match = GramQuery::and_(std::move(x->match),
q_of_set(x->exact));
+ }
+ x->prefix = x->exact;
+ x->suffix = x->exact;
+ x->exact.clear();
+ x->has_exact = false;
+ }
+
+ // Demote exact once the set size / string length crosses a threshold, and
trim
+ // prefix/suffix. The prototype's simplify also takes a `force` parameter,
but every call
+ // site passes false, so it is omitted here.
+ Info _simplify(Info x) {
+ if (x.has_exact) {
+ const size_t ml = min_str_len(x.exact);
+ const bool all_long = _all_long(x.exact);
+ // Three demotion conditions: too many enumerated strings
(kMaxExact); not too many,
+ // but each one already yields a gram, so enumerating further only
multiplies OR
+ // branches (kMaxLongExact); the strings have grown to >= 2n, so
more concatenation
+ // only makes exact longer without adding grams. In all three
cases demoting to
+ // prefix/suffix and landing the grams we have in match pays off
more.
+ if (x.exact.size() > RegexGramCompiler::kMaxExact ||
+ (all_long && x.exact.size() > kMaxLongExact) ||
+ ml >= static_cast<size_t>(2) * _scheme.min_len) {
+ _demote(&x);
+ }
+ }
+ if (!x.has_exact) {
+ _trim_set(&x.prefix, &x.match, false);
+ _trim_set(&x.suffix, &x.match, true);
+ }
+ return x;
+ }
+
+ // Concatenation xy.
+ Info _concat_info(Info x, Info y) {
+ Info r;
+ if (x.has_exact && y.has_exact) {
+ bool big = false;
+ std::set<std::string> c = cross(x.exact, y.exact, &big);
+ if (!big) {
+ r.has_exact = true;
+ r.exact = std::move(c);
+ } else {
+ _demote(&x);
+ _demote(&y);
+ }
+ }
+ if (!r.has_exact) {
+ if (x.has_exact) {
+ bool big = false;
+ std::set<std::string> c = cross(x.exact, y.prefix, &big);
+ if (big) {
+ _demote(&x);
+ r.prefix = x.prefix;
+ if (x.can_empty) {
+ r.prefix = uni(r.prefix, y.prefix);
+ }
+ } else {
+ r.prefix = std::move(c);
+ }
+ } else {
+ r.prefix = x.prefix;
+ if (x.can_empty) {
+ r.prefix = uni(r.prefix, pre(y));
+ }
+ }
+ if (y.has_exact) {
+ bool big = false;
+ std::set<std::string> c = cross(x.suffix, y.exact, &big);
+ if (big) {
+ _demote(&y);
+ r.suffix = y.suffix;
+ if (y.can_empty) {
+ r.suffix = uni(r.suffix, x.suffix);
+ }
+ } else {
+ r.suffix = std::move(c);
+ }
+ } else {
+ r.suffix = y.suffix;
+ if (y.can_empty) {
+ r.suffix = uni(r.suffix, suf(x));
+ }
+ }
+ // Boundary grams: some suffix of x and some prefix of y are
necessarily adjacent in
+ // a matching string, so their concatenation is necessarily a
substring of it and can
+ // be folded straight into match.
+ if (!x.has_exact && !y.has_exact) {
+ bool big = false;
+ std::set<std::string> c = cross(x.suffix, y.prefix, &big);
+ if (!big && !c.empty() && _all_long(c)) {
+ r.match = GramQuery::and_(std::move(r.match), q_of_set(c));
+ }
+ }
+ }
+ r.match = GramQuery::and_(std::move(r.match),
+ GramQuery::and_(std::move(x.match),
std::move(y.match)));
+ r.can_empty = x.can_empty && y.can_empty;
+ return _simplify(std::move(r));
+ }
+
+ // Alternation x|y.
+ Info _alt_info(Info x, Info y) {
+ Info r;
+ if (x.has_exact && y.has_exact) {
+ std::set<std::string> u = uni(x.exact, y.exact);
+ if (u.size() <= RegexGramCompiler::kMaxSet) {
+ r.has_exact = true;
+ r.exact = std::move(u);
+ } else {
+ _demote(&x);
+ _demote(&y);
+ }
+ }
+ if (!r.has_exact) {
+ _demote(&x);
+ _demote(&y);
+ r.prefix = uni(x.prefix, y.prefix);
+ r.suffix = uni(x.suffix, y.suffix);
+ }
+ r.can_empty = x.can_empty || y.can_empty;
+ r.match = GramQuery::or_(std::move(x.match), std::move(y.match));
+ return _simplify(std::move(r));
+ }
+
+ // x+: the repetition count is unknown, so all we can keep is "starts with
one of x's
+ // strings and ends with one of x's strings".
+ Info _plus_info(Info x) {
+ _demote(&x);
+ return _simplify(std::move(x));
+ }
+
+ // Bounded quantifier REPEAT `{m}` / `{m,}` / `{m,n}`: unroll exactly
+ // min(m, kMaxRepeatUnroll) times (which captures the boundary grams
across copies); when
+ // there may be more repetitions (rmax has no upper bound, or exceeds the
unrolled count)
+ // fall back to plus and keep only the two ends. Split out of _analyze to
reduce its
+ // complexity/length; the semantics are identical to the original switch
case.
+ Info _repeat_info(const RegexNode* n, int depth) {
+ // rmin < 0 can only come from a counter overflow during parsing; the
range is then
+ // untrustworthy, so be conservative.
+ if (n->kids.empty() || n->rmin < 0) {
+ return info_any_match();
+ }
+ if (n->rmin == 0 && n->rmax == 0) {
+ return info_empty();
+ }
+ if (n->rmin == 0) {
+ return info_any_match();
+ }
+ Info acc = info_empty();
+ const int reps = std::min(n->rmin, kMaxRepeatUnroll);
+ for (int k = 0; k < reps; k++) {
+ acc = _concat_info(std::move(acc), _analyze(n->kids[0].get(),
depth + 1));
+ }
+ if (n->rmax == n->rmin && n->rmin <= kMaxRepeatUnroll) {
+ return acc;
+ }
+ return _plus_info(std::move(acc));
+ }
+
+ Info _analyze(const RegexNode* n, int depth) {
+ if (n == nullptr || depth > kMaxAnalyzeDepth) {
+ return info_any_match();
+ }
+ switch (n->type) {
+ case RegexNode::Type::EMPTY:
+ return info_empty();
+ case RegexNode::Type::LIT: {
+ std::string lit = _fold(n->lit);
+ // Ruling R9: a literal containing NUL is not indexable; treat the
whole node as one
+ // unknown character.
+ if (lit.find('\0') != std::string::npos) {
+ return info_any_char();
+ }
+ Info i;
+ i.has_exact = true;
+ i.exact.insert(std::move(lit));
+ return _simplify(std::move(i));
+ }
+ case RegexNode::Type::CLASS: {
+ DCHECK(!n->big_class || n->cls.empty());
+ if (n->big_class || n->cls.empty()) {
+ return info_any_char();
+ }
+ Info i;
+ i.has_exact = true;
+ for (const auto& s : n->cls) {
+ std::string f = _fold(s);
+ // A single non-indexable element degrades the whole class to
an unknown
+ // character: dropping just that element would stop exact from
covering every
+ // possibility, and that is exactly where false negatives come
from.
+ if (f.find('\0') != std::string::npos) {
+ return info_any_char();
+ }
+ i.exact.insert(std::move(f)); // folding may create
duplicates; the set dedups
+ }
+ return _simplify(std::move(i));
+ }
+ case RegexNode::Type::ANY:
+ return info_any_char();
+ case RegexNode::Type::CAT: {
+ Info acc = info_empty();
+ for (const auto& k : n->kids) {
+ acc = _concat_info(std::move(acc), _analyze(k.get(), depth +
1));
+ }
+ return acc;
+ }
+ case RegexNode::Type::ALT: {
+ if (n->kids.empty()) {
+ return info_any_match();
+ }
+ Info acc = _analyze(n->kids[0].get(), depth + 1);
+ for (size_t k = 1; k < n->kids.size(); k++) {
+ acc = _alt_info(std::move(acc), _analyze(n->kids[k].get(),
depth + 1));
+ }
+ return acc;
+ }
+ case RegexNode::Type::STAR:
+ return info_any_match();
+ case RegexNode::Type::PLUS:
+ if (n->kids.empty()) {
+ return info_any_match();
+ }
+ return _plus_info(_analyze(n->kids[0].get(), depth + 1));
+ case RegexNode::Type::QUEST:
+ if (n->kids.empty()) {
+ return info_any_match();
+ }
+ return _alt_info(_analyze(n->kids[0].get(), depth + 1),
info_empty());
+ case RegexNode::Type::REPEAT:
+ return _repeat_info(n, depth);
+ }
+ return info_any_match();
+ }
+
+ GramExtractor& _extractor;
+ const GramScheme& _scheme;
+};
+
+} // namespace
+} // namespace regex_gram_compiler_detail
+
+RegexGramCompiler::RegexGramCompiler(const GramScheme& scheme) :
_extractor(scheme) {}
+
+void RegexGramCompiler::apply_gram_budget(GramQuery* q) {
+ if (q->leaf_count() > kMaxQueryGrams) {
+ *q = GramQuery::all();
+ }
+}
+
+Status RegexGramCompiler::compile_regexp(std::string_view pattern, GramQuery*
out) {
+ // Hyperscan compiles a C string, while scalar string fast paths and RE2
use the full
+ // length. No constraints after a raw NUL are universally required across
those paths.
+ if (pattern.find('\0') != std::string_view::npos) {
+ *out = GramQuery::all();
+ return Status::OK();
+ }
+ std::unique_ptr<RegexNode> root;
+ bool case_insensitive = false;
+ // A parse failure always falls back conservatively to ALL; never return
an error and fail
+ // the caller's query.
+ if (!parse_regex(pattern, &root, &case_insensitive).ok() || root ==
nullptr) {
+ *out = GramQuery::all();
+ return Status::OK();
+ }
+ // case_insensitive itself needs no extra handling: with lower_case=false
parse_regex has
+ // already expanded ASCII letter literals under `(?i)` into CLASS{c,C}
(Cox's approach); with
+ // lower_case=true both index and query fold to lower case, so it is
enough for CoxAnalyzer
+ // to fold on the AST leaves.
+ regex_gram_compiler_detail::CoxAnalyzer analyzer(_extractor);
+ *out = analyzer.compile(root.get());
Review Comment:
Confirmed, quantified and fixed in b873d817752034eebd3e9ecd233c7e6df82f69c7.
One compile of a single high-entropy literal, ASAN build (absolute numbers
inflated; the shape is the point):
| pattern bytes | before | after |
| --- | --- | --- |
| 256 | 15 ms | 18 ms |
| 512 | 143 ms | 39 ms |
| 1024 | 458 ms | 85 ms |
| 2048 | 2.99 s | 156 ms |
| 4096 | 4.36 s | 335 ms |
| LIKE, 1024 literal segments | 1.98 s | 47 ms |
Before: quadratic, once per segment, non-cancellable, result discarded.
After: 0.082 ms per byte at 1 KiB and 0.082 ms per byte at 4 KiB, so it is
linear.
`GramQuery::and_` and `or_` now return ALL as soon as a merged result passes
`kMaxLeaves`, so neither operand can ever exceed the cap and each step's copy /
dedupe / sort is bounded instead of growing with the pattern.
`RegexGramCompiler::kMaxQueryGrams` now aliases that constant so the two cannot
drift apart.
One consequence worth flagging, because it changes behaviour rather than
only cost: `and_(ALL, x)` is `x`, so an AND that crosses the cap restarts from
the next gram instead of poisoning everything after it. The 4 KiB pattern above
now compiles to a 56-leaf query where it used to compile to ALL. That is still
conservative -- every gram kept is one the pattern requires -- so it filters
where the old code gave up. For OR, ALL is absorbing, so an OR that crosses the
cap stays ALL, which is the only sound answer there.
Test: `GramQueryTest.CombiningStopsOnceThePlanIsPastTheSizeAnyoneWouldUse`
drives both combinators past the cap and requires neither to grow beyond it.
##########
be/src/storage/index/snii/writer/snii_compound_writer.cpp:
##########
@@ -576,6 +576,9 @@ Status SniiCompoundWriter::write_tail() {
LogicalIndexMetadataRef entry;
entry.index_id = w.index_id();
entry.index_suffix = w.index_suffix();
+ // Inherited groups above never carry this: only a positional index
can be inherited
Review Comment:
Confirmed and fixed in fd3990cf5cfeec2e972f886790005363947f14ce. Tracing it
turned up two defects, not one:
1. `MetadataDirectory::decode` never set `dropped_postings` on a decoded
entry at all. The flag was only consumed on the encode side, so **every** entry
read back reported `false`, inherited or not.
2. `InheritedLogicalIndex` / `InheritedGroup` carried no such state, so the
re-emitted directory omitted `kFeatureDroppedPostings` exactly as described.
I took the "per-index state" option rather than "retain the source feature",
because retaining the feature is not precise enough: it is raised for the
directory, so carrying it back on every inverted entry makes a rewrite that
keeps only the *non*-dropping indexes declare it anyway, fencing an old reader
off a container that holds nothing it cannot parse. A first attempt did exactly
that and the new inheritance test caught it.
So the fact is now recorded per index, as
`SniiLogicalIndexMetadataPB.dropped_postings` (field 8), written only when true
so a directory that drops nothing keeps its pre-feature bytes -- the same byte
gate `kind` has. The directory-wide required feature stays as the old-reader
fence, and decode now requires the two to agree in both directions, exactly as
the blob feature already does. The snapshot carries the per-index bit through
`load_inherited_index` and `inherit()`, and the stale comment at the new-writer
loop that claimed inherited groups were handled there is corrected.
Tests:
`SniiCompoundWriterInherit.InheritedIndexKeepsTheDroppedPostingDeclaration`
builds a `kHighDfDigestDivisor`-row gram source that really does drop a posting
(asserted, so the test cannot pass for the wrong reason), inherits it alongside
a freshly written index, and requires the first to keep the declaration and the
second not to acquire it.
`SniiMetadataDirectory.ADecodedEntryReportsItsOwnDroppedPostingState` and
`.TheDroppedPostingFeatureAndTheEntriesMustAgree` pin the format half. On your
point that the inheritance suite ran three rows: `test_gram_metadata_inherit`
gained a second table of 4,000 rows in one segment, where the commonest grams
really are over the line, and it compares index-on against index-off on both
sides of a `BUILD INDEX`.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]