github-actions[bot] commented on code in PR #67538:
URL: https://github.com/apache/doris/pull/67538#discussion_r3986191672
##########
be/src/storage/index/snii/snii_index_reader.cpp:
##########
@@ -651,6 +737,12 @@ Status SniiIndexReader::_query(const IndexQueryContextPtr&
context, const std::s
const ::doris::snii::reader::LogicalIndexReader* logical_reader = nullptr;
RETURN_IF_ERROR(_get_logical_reader(context, &searcher_cache_handle,
&uncached_reader,
&logical_reader));
+ if (analyzed_query && logical_reader->gram_scheme().has_value() &&
Review Comment:
[P1] Treat an absent persisted scheme as a mismatch when the current
analyzer has a gram scheme. Legacy ngram tokenizers without `mode` write
`gram_scheme=nullopt`; after recovering the table with the same policy name
recreated in gram mode, `current_gram_scheme` is populated, but this
`has_value()` guard skips the comparison. MATCH then analyzes `abcdef` into
current dense grams and looks them up in the old legacy dictionary (for
example, fixed bigrams), so it can return no rows before fallback even though
current-analyzer evaluation matches. LIKE/REGEXP already refuse a reader with
no scheme. Please compare the optionals directly when the current scheme is
populated and add legacy-ngram-to-gram recovery coverage for MATCH_ANY/ALL.
##########
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:
[P1] Apply the gram/work budget during compilation, before constructing the
full query. Once a long high-entropy regexp literal is demoted, every following
byte adds a boundary gram and `GramQuery::and_` copies/deduplicates/sorts the
entire growing vector; LIKE has the same issue when many literal segments are
repeatedly flushed and ANDed. Only after all of that does this shared call
collapse >64 leaves to `ALL`. A user-supplied pattern can therefore incur
superlinear, non-cancellable CPU once per segment even though the result is
discarded. Please carry a budget through both compiler paths (or combine
literal runs linearly) and stop as soon as the conservative result must be
`ALL`.
##########
be/src/storage/index/snii/snii_index_reader.cpp:
##########
@@ -156,6 +164,23 @@ bool uses_phrase_frequency_scoring(InvertedIndexQueryType
query_type,
query_type == InvertedIndexQueryType::MATCH_PHRASE_PREFIX_QUERY);
}
+// Query types whose terms come out of the current analyzer. On a gram-family
index those
+// terms are grams, and they only mean what the segment's own grams mean when
both were cut
+// by the same scheme; a gram query compiles against the segment's scheme
itself and a raw
+// pattern query never analyzes, so neither is affected.
Review Comment:
[P1] Include `MATCH_REGEXP_QUERY` in the gram-scheme fence. Its pattern is
raw, but its semantics still depend on analyzer output: scalar
`FunctionMatchRegexp` regex-matches terms produced from each row by the current
analyzer, whereas SNII regex-matches the persisted segment dictionary. For an
explicit dense-3/3 segment recovered under dense-4/4, row `abcd` with pattern
`^abcd$` matches scalar token `abcd`, but the old dictionary has only
`abc`/`bcd`, so the index returns an empty exact bitmap. This subtype also
keeps the scheme-blind result cache enabled. Please classify it by
analyzer-dependent data terms, skip on mismatch (including a missing persisted
scheme), and add indexed/unindexed recovery parity.
##########
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:
[P1] Treat overlong UTF-8 as ill-formed here instead of normalizing it. For
example, `C0 AF` is decoded as U+002F and later re-encoded as `/`, so a regexp
literal `abc<C0 AF>def` asks the dense gram index for slash-bearing grams that
a row containing the original bytes never stored (the extractor treats those
bytes as a non-ASCII separator). Hyperscan/RE2 reject this pattern, but with
extended-regex fallback enabled the byte-oriented Boost path can match the
original bytes; intersecting the approximate bitmap then drops a true row
before scalar recheck. The later UTF-8 guard sees only the normalized `/`.
Please reject overlong/surrogate/out-of-range sequences in the decoder and
cover the real Boost fallback path.
##########
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:
[P1] Keep the omitted `max_gram` default consistent with BE. This validator
substitutes 16 but does not persist it, while `GramScheme` now defaults the
same missing property to 4. As a result, `mode=sparse,min_gram=5` passes FE DDL
validation and is stored without `max_gram`, then BE rejects `max_len(4) <
min_len(5)` when the SNII writer initializes, so the first load/index build
fails after apparently valid DDL. This is a follow-on to the earlier default
change, not the same issue: please validate against 4 (or persist one canonical
default) and add the omitted-max case.
##########
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:
[P2] Preserve the dropped-posting feature fence for inherited indexes.
`BUILD INDEX` can put any unchanged docs-only gram index in `inherit_keys`; its
physical dictionary (including locator-less bit-5 entries) and metadata group
are copied verbatim, but `InheritedLogicalIndex`/`InheritedGroup` carries no
dropped-posting or source-feature state, so this reconstructed entry leaves
`dropped_postings=false` and the new directory omits `kFeatureDroppedPostings`.
That reintroduces the old-reader misparse the feature bit was added to prevent.
The inheritance tests use only three rows, below the stop-gram floor. Please
retain the source feature (or per-index state) through the snapshot and cover
an inherited >=2,000-row stopped-posting index.
##########
be/src/storage/index/snii/reader/logical_index_reader.h:
##########
@@ -176,11 +176,23 @@ class LogicalIndexReader {
const format::SectionRefs& section_refs() const { return
core_.section_refs; }
const format::StatsBlock& stats() const { return core_.stats; }
+ // Bounds on the df of this index's most common terms, resident since the
segment was
+ // opened. Lets a caller decide a term is too common to be worth reading
without issuing
+ // the dictionary read that would tell it exactly how common. Empty on
indexes written
+ // before the digest existed, in which case there is no bound and df must
be read.
+ const format::HighDfTerms& high_df_terms() const { return
core_.high_df_terms; }
Review Comment:
[P2] Include the resident high-DF digest in `memory_usage()`. Core-metadata
decode owns up to 4,096 `uint64_t` hashes plus parallel `uint32_t` dfs (about
49 KiB before capacity slack) for the full logical-reader lifetime, but the
searcher-cache charge currently counts the other resident metadata and omits
both vectors. With one reader per cached segment/index this can materially
overrun the configured cache capacity. Please add both capacities with
saturating arithmetic and test that a populated digest increases the cache
charge.
--
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]