This is an automated email from the ASF dual-hosted git repository.
xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new 7dffad77c18 Reduce memory footprint and make FST regexp matching
interruptible (#18754)
7dffad77c18 is described below
commit 7dffad77c18dfcdc3520a7d3e160059a4ed1d490
Author: Xiang Fu <[email protected]>
AuthorDate: Mon Jun 15 00:57:50 2026 -0700
Reduce memory footprint and make FST regexp matching interruptible (#18754)
* Reduce memory footprint and make FST regexp matching interruptible
REGEXP_LIKE backed by an FST index walks the query automaton over the FST
via RegexpMatcher / RegexpMatcherCaseInsensitive. On a high-cardinality,
long-string column a broad regexp (anything containing `.*`) visits a huge
number of paths, and the traversal had three problems that together turned
memory pressure into server-wide heap OOMs:
1. Every pushed path carried a full copy of its accumulated input prefix in
an
IntsRefBuilder (newInput.copyInts(...) per arc), an O(paths x key-length)
allocation. The prefix was write-only -- it was never read to produce the
result -- so it was pure garbage. Removed the field.
2. Matches were retained as Path objects in an `endNodes` list until the
whole
traversal finished, then materialized into a boxed List<Long>, then
copied
into the result bitmap -- holding the entire match set (and copied FST
arcs)
live on the heap. Matches are now emitted to an IntConsumer as soon as a
final state is reached, so the readers add straight into the
RoaringBitmap.
3. The traversal loop never checked for query termination, so the
OOM-protection
framework (and query deadline / interrupt) could not stop it. All query
threads stayed pinned in the loop and the JVM was killed instead of the
offending query. The loop now calls
QueryThreadContext.checkTerminationAndSampleUsagePeriodically(...), and
the
readers let QueryException propagate unwrapped so the kill/timeout is
honored.
JMH (BenchmarkFSTRegexpMatcher, 500k keys x 40 chars), allocation per op:
FST `.*` 206 MB -> 67 MB (-67%), 62.7 ms -> 18.7 ms
FST `.*9999` 193 MB -> 67 MB (-66%), 27.2 ms -> 19.7 ms
IFST `.*` 299 MB -> 134 MB (-55%), 74.8 ms -> 30.0 ms
IFST `.*9999` 234 MB -> 108 MB (-54%)
Selective queries (e.g. `key0001.*`) are unchanged.
Added regression tests asserting full enumeration through the new consumer
path
and that an interrupt mid-match surfaces EarlyTerminationException both
from the
matcher and through LuceneFSTIndexReader/LuceneIFSTIndexReader#getDictIds.
* Close off-heap FST buffers in benchmark teardown
The readers' close() is a no-op, so the PinotDataBuffer instances returned
by
loadBigEndianFile own the mapped memory and must be closed explicitly;
otherwise the mapping leaks and can block temp-dir deletion.
---
.../pinot/perf/BenchmarkFSTRegexpMatcher.java | 160 +++++++++++++++++++++
.../index/readers/LuceneFSTIndexReader.java | 10 +-
.../index/readers/LuceneIFSTIndexReader.java | 10 +-
.../segment/local/utils/fst/RegexpMatcher.java | 121 +++++++---------
.../utils/fst/RegexpMatcherCaseInsensitive.java | 152 +++++++++-----------
.../segment/local/utils/fst/FSTBuilderTest.java | 40 ++++++
.../segment/local/utils/fst/IFSTBuilderTest.java | 38 +++++
7 files changed, 368 insertions(+), 163 deletions(-)
diff --git
a/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkFSTRegexpMatcher.java
b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkFSTRegexpMatcher.java
new file mode 100644
index 00000000000..15c900a1e2d
--- /dev/null
+++
b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkFSTRegexpMatcher.java
@@ -0,0 +1,160 @@
+/**
+ * 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.
+ */
+package org.apache.pinot.perf;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.io.FileUtils;
+import org.apache.lucene.store.OutputStreamDataOutput;
+import org.apache.lucene.util.BytesRef;
+import org.apache.lucene.util.fst.FST;
+import
org.apache.pinot.segment.local.segment.index.readers.LuceneFSTIndexReader;
+import
org.apache.pinot.segment.local.segment.index.readers.LuceneIFSTIndexReader;
+import org.apache.pinot.segment.local.utils.fst.FSTBuilder;
+import org.apache.pinot.segment.local.utils.fst.IFSTBuilder;
+import org.apache.pinot.segment.spi.memory.PinotDataBuffer;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.options.ChainedOptionsBuilder;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+
+
+/**
+ * Benchmark for FST/IFST regexp matching (REGEXP_LIKE backed by an FST index).
+ *
+ * This exercises {@code LuceneFSTIndexReader#getDictIds} / {@code
LuceneIFSTIndexReader#getDictIds}, the production
+ * path used by the FST-based regexp predicate evaluator. It is designed to
highlight the memory footprint of the
+ * automaton-over-FST traversal for high-cardinality, long-string columns (the
case that caused server heap OOMs).
+ *
+ * Run with the GC profiler to see the allocation reduction, e.g.:
+ * java -cp 'pinot-perf/target/pinot-perf-pkg/lib/*' org.openjdk.jmh.Main \
+ * org.apache.pinot.perf.BenchmarkFSTRegexpMatcher -prof gc -wi 2 -i 3 -f
1 -r 5s -w 5s
+ */
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@Fork(1)
+@Warmup(iterations = 3, time = 5)
+@Measurement(iterations = 5, time = 5)
+@State(Scope.Benchmark)
+public class BenchmarkFSTRegexpMatcher {
+
+ /// Number of distinct keys (column cardinality).
+ @Param({"500000"})
+ public int _cardinality;
+
+ /// Length of the key prefix padding, simulating a "long string" column.
+ @Param({"40"})
+ public int _keyLength;
+
+ /// Regexp patterns: a broad `.*` (worst case, matches everything), a
selective prefix, and a selective suffix.
+ @Param({".*", "key0001.*", ".*9999"})
+ public String _regex;
+
+ private File _tempDir;
+ private PinotDataBuffer _fstBuffer;
+ private PinotDataBuffer _ifstBuffer;
+ private LuceneFSTIndexReader _fstReader;
+ private LuceneIFSTIndexReader _ifstReader;
+
+ @Setup(Level.Trial)
+ public void setUp()
+ throws IOException {
+ _tempDir = new File(FileUtils.getTempDirectory(),
"BenchmarkFSTRegexpMatcher");
+ FileUtils.deleteDirectory(_tempDir);
+ FileUtils.forceMkdir(_tempDir);
+
+ // Build a high-cardinality dictionary of long string keys: "key" +
zero-padded id, padded to _keyLength.
+ SortedMap<String, Integer> entries = new TreeMap<>();
+ for (int i = 0; i < _cardinality; i++) {
+ String key = String.format("key%0" + (_keyLength - 3) + "d", i);
+ entries.put(key, i);
+ }
+
+ FST<Long> fst = FSTBuilder.buildFST(entries);
+ File fstFile = new File(_tempDir, "fst.lucene");
+ try (FileOutputStream outputStream = new FileOutputStream(fstFile);
+ OutputStreamDataOutput dataOutput = new
OutputStreamDataOutput(outputStream)) {
+ fst.save(dataOutput, dataOutput);
+ }
+ _fstBuffer = PinotDataBuffer.loadBigEndianFile(fstFile);
+ _fstReader = new LuceneFSTIndexReader(_fstBuffer);
+
+ FST<BytesRef> ifst = IFSTBuilder.buildIFST(entries);
+ File ifstFile = new File(_tempDir, "ifst.lucene");
+ try (FileOutputStream outputStream = new FileOutputStream(ifstFile);
+ OutputStreamDataOutput dataOutput = new
OutputStreamDataOutput(outputStream)) {
+ ifst.save(dataOutput, dataOutput);
+ }
+ _ifstBuffer = PinotDataBuffer.loadBigEndianFile(ifstFile);
+ _ifstReader = new LuceneIFSTIndexReader(_ifstBuffer);
+ }
+
+ @TearDown(Level.Trial)
+ public void tearDown()
+ throws IOException {
+ if (_fstReader != null) {
+ _fstReader.close();
+ }
+ if (_ifstReader != null) {
+ _ifstReader.close();
+ }
+ // The readers' close() is a no-op; the off-heap buffers own the mapped
memory and must be closed explicitly,
+ // otherwise the mapping stays live and can prevent the temp dir from
being deleted.
+ if (_fstBuffer != null) {
+ _fstBuffer.close();
+ }
+ if (_ifstBuffer != null) {
+ _ifstBuffer.close();
+ }
+ FileUtils.deleteDirectory(_tempDir);
+ }
+
+ @Benchmark
+ public void fstGetDictIds(Blackhole blackhole) {
+ blackhole.consume(_fstReader.getDictIds(_regex));
+ }
+
+ @Benchmark
+ public void ifstGetDictIds(Blackhole blackhole) {
+ blackhole.consume(_ifstReader.getDictIds(_regex));
+ }
+
+ public static void main(String[] args)
+ throws Exception {
+ ChainedOptionsBuilder opt = new
OptionsBuilder().include(BenchmarkFSTRegexpMatcher.class.getSimpleName());
+ new Runner(opt.build()).run();
+ }
+}
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/LuceneFSTIndexReader.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/LuceneFSTIndexReader.java
index 324d15a0f3e..1f81f0c25e6 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/LuceneFSTIndexReader.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/LuceneFSTIndexReader.java
@@ -19,7 +19,6 @@
package org.apache.pinot.segment.local.segment.index.readers;
import java.io.IOException;
-import java.util.List;
import org.apache.lucene.util.fst.FST;
import org.apache.lucene.util.fst.OffHeapFSTStore;
import org.apache.lucene.util.fst.PositiveIntOutputs;
@@ -27,6 +26,7 @@ import
org.apache.pinot.segment.local.utils.fst.PinotBufferIndexInput;
import org.apache.pinot.segment.local.utils.fst.RegexpMatcher;
import org.apache.pinot.segment.spi.index.reader.TextIndexReader;
import org.apache.pinot.segment.spi.memory.PinotDataBuffer;
+import org.apache.pinot.spi.exception.QueryException;
import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
import org.roaringbitmap.buffer.MutableRoaringBitmap;
import org.slf4j.Logger;
@@ -61,11 +61,11 @@ public class LuceneFSTIndexReader implements
TextIndexReader {
public ImmutableRoaringBitmap getDictIds(String searchQuery) {
try {
MutableRoaringBitmap dictIds = new MutableRoaringBitmap();
- List<Long> matchingIds = RegexpMatcher.regexMatch(searchQuery, _fst);
- for (Long matchingId : matchingIds) {
- dictIds.add(matchingId.intValue());
- }
+ RegexpMatcher.regexMatch(searchQuery, _fst, dictIds::add);
return dictIds.toImmutableRoaringBitmap();
+ } catch (QueryException ex) {
+ // Let query termination exceptions (timeout, OOM-protection kill)
propagate as-is.
+ throw ex;
} catch (Exception ex) {
LOGGER.error("Error getting matching Ids from FST", ex);
throw new RuntimeException(ex);
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/LuceneIFSTIndexReader.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/LuceneIFSTIndexReader.java
index 3edacf009c7..fa1114051bc 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/LuceneIFSTIndexReader.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/LuceneIFSTIndexReader.java
@@ -19,7 +19,6 @@
package org.apache.pinot.segment.local.segment.index.readers;
import java.io.IOException;
-import java.util.List;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.fst.ByteSequenceOutputs;
import org.apache.lucene.util.fst.FST;
@@ -28,6 +27,7 @@ import
org.apache.pinot.segment.local.utils.fst.PinotBufferIndexInput;
import org.apache.pinot.segment.local.utils.fst.RegexpMatcherCaseInsensitive;
import org.apache.pinot.segment.spi.index.reader.TextIndexReader;
import org.apache.pinot.segment.spi.memory.PinotDataBuffer;
+import org.apache.pinot.spi.exception.QueryException;
import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
import org.roaringbitmap.buffer.MutableRoaringBitmap;
import org.slf4j.Logger;
@@ -62,11 +62,11 @@ public class LuceneIFSTIndexReader implements
TextIndexReader {
public ImmutableRoaringBitmap getDictIds(String searchQuery) {
try {
MutableRoaringBitmap dictIds = new MutableRoaringBitmap();
- List<Long> matchingIds =
RegexpMatcherCaseInsensitive.regexMatch(searchQuery, _ifst);
- for (Long matchingId : matchingIds) {
- dictIds.add(matchingId.intValue());
- }
+ RegexpMatcherCaseInsensitive.regexMatch(searchQuery, _ifst,
dictIds::add);
return dictIds.toImmutableRoaringBitmap();
+ } catch (QueryException ex) {
+ // Let query termination exceptions (timeout, OOM-protection kill)
propagate as-is.
+ throw ex;
} catch (Exception ex) {
LOGGER.error("Error getting matching Ids from FST", ex);
throw new RuntimeException(ex);
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/fst/RegexpMatcher.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/fst/RegexpMatcher.java
index 8afb8ce6db6..938e52140e0 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/fst/RegexpMatcher.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/fst/RegexpMatcher.java
@@ -20,25 +20,25 @@ package org.apache.pinot.segment.local.utils.fst;
import java.io.IOException;
import java.util.ArrayList;
-import java.util.Collections;
import java.util.List;
-import org.apache.lucene.util.IntsRefBuilder;
+import java.util.function.IntConsumer;
import org.apache.lucene.util.automaton.Automaton;
import org.apache.lucene.util.automaton.CharacterRunAutomaton;
import org.apache.lucene.util.automaton.RegExp;
import org.apache.lucene.util.automaton.Transition;
import org.apache.lucene.util.fst.FST;
import org.apache.lucene.util.fst.Util;
+import org.apache.pinot.spi.query.QueryThreadContext;
-/**
- * RegexpMatcher is a helper to retrieve matching values for a given regexp
query.
- * Regexp query is converted into an automaton and we run the matching
algorithm on FST.
- *
- * Two main functions of this class are
- * regexMatchOnFST() Function runs matching on FST (See function comments
for more details)
- * match(input) Function builds the automaton and matches given input.
- */
+/// RegexpMatcher is a helper to retrieve matching values for a given regexp
query.
+/// Regexp query is converted into an automaton, and we run the matching
algorithm on FST.
+///
+/// Two main functions of this class are
+/// regexMatchOnFST() Function runs matching on FST (See function comments
for more details)
+/// match(input) Function builds the automaton and matches given input.
+///
+/// This class is not thread-safe. Create a new instance (or use the static
helper) per query.
public class RegexpMatcher {
private final FST<Long> _fst;
private final Automaton _automaton;
@@ -48,9 +48,12 @@ public class RegexpMatcher {
_automaton = (new RegExp(regexQuery)).toAutomaton();
}
- public static List<Long> regexMatch(String regexQuery, FST<Long> fst)
+ /// Runs matching of the given regexp query on the FST, emitting the value
(dict id) of each matched entry to the
+ /// given consumer. Values are emitted as the traversal hits final states,
so the caller can collect them without
+ /// this class materializing the full result set in memory.
+ public static void regexMatch(String regexQuery, FST<Long> fst, IntConsumer
dictIdConsumer)
throws IOException {
- return new RegexpMatcher(regexQuery, fst).regexMatchOnFST();
+ new RegexpMatcher(regexQuery, fst).regexMatchOnFST(dictIdConsumer);
}
// Matches "input" string with _regexQuery Automaton.
@@ -59,97 +62,79 @@ public class RegexpMatcher {
return characterRunAutomaton.run(input);
}
- /**
- * This function runs matching on automaton built from regexQuery and the
FST.
- * FST stores key (string) to a value (Long). Both are state machines and
state transition is based on
- * a input character.
- *
- * This algorithm starts with Queue containing (Automaton Start Node, FST
Start Node).
- * Each step an entry is popped from the queue:
- * 1) if the automaton state is accept and the FST Node is final (i.e.
end node) then the value stored for that FST
- * is added to the set of result.
- * 2) Else next set of transitions on automaton are gathered and for each
transition target node for that character
- * is figured out in FST Node, resulting pair of (automaton state, fst
node) are added to the queue.
- * 3) This process is bound to complete since we are making progression
on the FST (which is a DAG) towards final
- * nodes.
- * @return
- * @throws IOException
- */
- public List<Long> regexMatchOnFST()
+ /// This function runs matching on automaton built from regexQuery and the
FST.
+ /// FST stores key (string) to a value (Long). Both are state machines and
state transition is based on an input
+ /// character.
+ ///
+ /// This algorithm starts with a stack containing (Automaton Start Node, FST
Start Node).
+ /// Each step an entry is popped from the stack (DFS order to bound the
frontier size):
+ /// 1) if the automaton state is accept and the FST Node is final (i.e.
end node) then the value stored for that
+ /// FST node is emitted to the consumer.
+ /// 2) Else next set of transitions on automaton are gathered and for each
transition target node for that
+ /// character is figured out in FST Node, resulting pair of (automaton
state, fst node) are added to the stack.
+ /// 3) This process is bound to complete since we are making progression
on the FST (which is a DAG) towards final
+ /// nodes.
+ ///
+ /// A broad regexp on a high-cardinality column can visit a huge number of
paths, so the loop periodically checks
+ /// for query termination (timeout or OOM-protection kill) to remain
interruptible.
+ public void regexMatchOnFST(IntConsumer dictIdConsumer)
throws IOException {
- final List<Path<Long>> queue = new ArrayList<>();
- final List<Path<Long>> endNodes = new ArrayList<>();
if (_automaton.getNumStates() == 0) {
- return Collections.emptyList();
+ return;
}
- // Automaton start state and FST start node is added to the queue.
- queue.add(new Path<>(0, _fst.getFirstArc(new FST.Arc<Long>()),
_fst.outputs.getNoOutput(), new IntsRefBuilder()));
+ // Automaton start state and FST start node is added to the stack.
+ List<Path<Long>> stack = new ArrayList<>();
+ stack.add(new Path<>(0, _fst.getFirstArc(new FST.Arc<>()),
_fst.outputs.getNoOutput()));
- final FST.Arc<Long> scratchArc = new FST.Arc<>();
- final FST.BytesReader fstReader = _fst.getBytesReader();
+ FST.Arc<Long> scratchArc = new FST.Arc<>();
+ FST.BytesReader fstReader = _fst.getBytesReader();
Transition t = new Transition();
- while (!queue.isEmpty()) {
- final Path<Long> path = queue.remove(queue.size() - 1);
+ int numPathsProcessed = 0;
+ while (!stack.isEmpty()) {
+
QueryThreadContext.checkTerminationAndSampleUsagePeriodically(numPathsProcessed++,
+ "RegexpMatcher#regexMatchOnFST");
+ Path<Long> path = stack.remove(stack.size() - 1);
- // If automaton is in accept state and the fstNode is final (i.e. end
node) then add the entry to endNodes which
- // contains the result set.
- if (_automaton.isAccept(path._state)) {
- if (path._fstNode.isFinal()) {
- endNodes.add(path);
- }
+ // If automaton is in accept state and the fstNode is final (i.e. end
node) then emit the matched value.
+ if (_automaton.isAccept(path._state) && path._fstNode.isFinal()) {
+ dictIdConsumer.accept(path._output.intValue());
}
// Gather next set of transitions on automaton and find target nodes in
FST.
- IntsRefBuilder currentInput = path._input;
int count = _automaton.initTransition(path._state, t);
for (int i = 0; i < count; i++) {
_automaton.getNextTransition(t);
- final int min = t.min;
- final int max = t.max;
+ int min = t.min;
+ int max = t.max;
if (min == max) {
- final FST.Arc<Long> nextArc = _fst.findTargetArc(t.min,
path._fstNode, scratchArc, fstReader);
+ FST.Arc<Long> nextArc = _fst.findTargetArc(t.min, path._fstNode,
scratchArc, fstReader);
if (nextArc != null) {
- final IntsRefBuilder newInput = new IntsRefBuilder();
- newInput.copyInts(currentInput.get());
- newInput.append(t.min);
- queue.add(new Path<Long>(t.dest, new
FST.Arc<Long>().copyFrom(nextArc),
- _fst.outputs.add(path._output, nextArc.output()), newInput));
+ stack.add(new Path<>(t.dest, new FST.Arc<Long>().copyFrom(nextArc),
+ _fst.outputs.add(path._output, nextArc.output())));
}
} else {
FST.Arc<Long> nextArc = Util.readCeilArc(min, _fst, path._fstNode,
scratchArc, fstReader);
while (nextArc != null && nextArc.label() <= max) {
- final IntsRefBuilder newInput = new IntsRefBuilder();
- newInput.copyInts(currentInput.get());
- newInput.append(nextArc.label());
- queue.add(new Path<>(t.dest, new FST.Arc<Long>().copyFrom(nextArc),
- _fst.outputs.add(path._output, nextArc.output()), newInput));
+ stack.add(new Path<>(t.dest, new FST.Arc<Long>().copyFrom(nextArc),
+ _fst.outputs.add(path._output, nextArc.output())));
nextArc = nextArc.isLast() ? null : _fst.readNextRealArc(nextArc,
fstReader);
}
}
}
}
-
- // From the result set of matched entries gather the values stored and
return.
- ArrayList<Long> matchedIds = new ArrayList<>();
- for (Path<Long> path : endNodes) {
- matchedIds.add(path._output);
- }
- return matchedIds;
}
public static final class Path<T> {
public final int _state;
public final FST.Arc<T> _fstNode;
public final T _output;
- public final IntsRefBuilder _input;
- public Path(int state, FST.Arc<T> fstNode, T output, IntsRefBuilder input)
{
+ public Path(int state, FST.Arc<T> fstNode, T output) {
_state = state;
_fstNode = fstNode;
_output = output;
- _input = input;
}
}
}
diff --git
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/fst/RegexpMatcherCaseInsensitive.java
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/fst/RegexpMatcherCaseInsensitive.java
index 03fb87135a2..6e4fb356aeb 100644
---
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/fst/RegexpMatcherCaseInsensitive.java
+++
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/fst/RegexpMatcherCaseInsensitive.java
@@ -20,25 +20,26 @@ package org.apache.pinot.segment.local.utils.fst;
import java.io.IOException;
import java.util.ArrayList;
-import java.util.Collections;
import java.util.List;
+import java.util.function.IntConsumer;
import org.apache.lucene.util.BytesRef;
-import org.apache.lucene.util.IntsRefBuilder;
import org.apache.lucene.util.automaton.Automaton;
import org.apache.lucene.util.automaton.CharacterRunAutomaton;
import org.apache.lucene.util.automaton.RegExp;
import org.apache.lucene.util.automaton.Transition;
import org.apache.lucene.util.fst.FST;
+import org.apache.lucene.util.fst.Util;
+import org.apache.pinot.spi.query.QueryThreadContext;
-/**
- * RegexpMatcherCaseInsensitive is a specialized helper to retrieve matching
values for case-insensitive regexp queries.
- * This class handles only case-insensitive matching using FST<BytesRef> type.
- *
- * The regexp query is converted to lowercase and then into an automaton for
matching against the FST.
- * This class is separate from RegexpMatcher because case-insensitive logic is
fundamentally different
- * from case-sensitive logic and doesn't share code.
- */
+/// RegexpMatcherCaseInsensitive is a specialized helper to retrieve matching
values for case-insensitive regexp
+/// queries. This class handles only case-insensitive matching using
`FST<BytesRef>` type.
+///
+/// The regexp query is converted to lowercase and then into an automaton for
matching against the FST.
+/// This class is separate from RegexpMatcher because case-insensitive logic
is fundamentally different
+/// from case-sensitive logic and doesn't share code.
+///
+/// This class is not thread-safe. Create a new instance (or use the static
helper) per query.
public class RegexpMatcherCaseInsensitive {
private final FST<BytesRef> _ifst;
private final Automaton _automaton;
@@ -49,9 +50,12 @@ public class RegexpMatcherCaseInsensitive {
_automaton = (new RegExp(regexQuery.toLowerCase())).toAutomaton();
}
- public static List<Long> regexMatch(String regexQuery, FST<BytesRef> ifst)
+ /// Runs case-insensitive matching of the given regexp query on the FST,
emitting the values (dict ids) of each
+ /// matched entry to the given consumer. Values are emitted as the traversal
hits final states, so the caller can
+ /// collect them without this class materializing the full result set in
memory.
+ public static void regexMatch(String regexQuery, FST<BytesRef> ifst,
IntConsumer dictIdConsumer)
throws IOException {
- return new RegexpMatcherCaseInsensitive(regexQuery,
ifst).regexMatchOnFST();
+ new RegexpMatcherCaseInsensitive(regexQuery,
ifst).regexMatchOnFST(dictIdConsumer);
}
// Matches "input" string with _regexQuery Automaton (case-insensitive).
@@ -60,114 +64,92 @@ public class RegexpMatcherCaseInsensitive {
return characterRunAutomaton.run(input.toLowerCase());
}
- /**
- * This function runs case-insensitive matching on automaton built from
regexQuery and the FST.
- * FST stores key (string) to a value (BytesRef). Both are state machines
and state transition is based on
- * a input character.
- *
- * This algorithm starts with Queue containing (Automaton Start Node, FST
Start Node).
- * Each step an entry is popped from the queue:
- * 1) if the automaton state is accept and the FST Node is final (i.e.
end node) then the value stored for that FST
- * is added to the set of result.
- * 2) Else next set of transitions on automaton are gathered and for each
transition target node for that character
- * is figured out in FST Node, resulting pair of (automaton state, fst
node) are added to the queue.
- * 3) This process is bound to complete since we are making progression
on the FST (which is a DAG) towards final
- * nodes.
- * @return List of matched IDs
- * @throws IOException
- */
- public List<Long> regexMatchOnFST()
+ /// This function runs case-insensitive matching on automaton built from
regexQuery and the FST.
+ /// FST stores key (string) to a value (BytesRef). Both are state machines
and state transition is based on an input
+ /// character.
+ ///
+ /// This algorithm starts with a stack containing (Automaton Start Node, FST
Start Node).
+ /// Each step an entry is popped from the stack (DFS order to bound the
frontier size):
+ /// 1) if the automaton state is accept and the FST Node is final (i.e.
end node) then the values stored for that
+ /// FST node are emitted to the consumer.
+ /// 2) Else next set of transitions on automaton are gathered and for each
transition target node for that
+ /// character is figured out in FST Node, resulting pair of (automaton
state, fst node) are added to the stack.
+ /// 3) This process is bound to complete since we are making progression
on the FST (which is a DAG) towards final
+ /// nodes.
+ ///
+ /// A broad regexp on a high-cardinality column can visit a huge number of
paths, so the loop periodically checks
+ /// for query termination (timeout or OOM-protection kill) to remain
interruptible.
+ public void regexMatchOnFST(IntConsumer dictIdConsumer)
throws IOException {
- final List<Path<BytesRef>> queue = new ArrayList<>();
- final List<Path<BytesRef>> endNodes = new ArrayList<>();
if (_automaton.getNumStates() == 0) {
- return Collections.emptyList();
+ return;
}
- // Automaton start state and FST start node is added to the queue.
- queue.add(
- new Path<>(0, _ifst.getFirstArc(new FST.Arc<BytesRef>()),
_ifst.outputs.getNoOutput(), new IntsRefBuilder()));
+ // Automaton start state and FST start node is added to the stack.
+ List<Path<BytesRef>> stack = new ArrayList<>();
+ stack.add(new Path<>(0, _ifst.getFirstArc(new FST.Arc<>()),
_ifst.outputs.getNoOutput()));
- final FST.Arc<BytesRef> scratchArc = new FST.Arc<>();
- final FST.BytesReader fstReader = _ifst.getBytesReader();
+ FST.Arc<BytesRef> scratchArc = new FST.Arc<>();
+ FST.BytesReader fstReader = _ifst.getBytesReader();
Transition t = new Transition();
- while (!queue.isEmpty()) {
- final Path<BytesRef> path = queue.remove(queue.size() - 1);
+ int numPathsProcessed = 0;
+ while (!stack.isEmpty()) {
+
QueryThreadContext.checkTerminationAndSampleUsagePeriodically(numPathsProcessed++,
+ "RegexpMatcherCaseInsensitive#regexMatchOnFST");
+ Path<BytesRef> path = stack.remove(stack.size() - 1);
- // If automaton is in accept state and the fstNode is final (i.e. end
node) then add the entry to endNodes which
- // contains the result set.
- if (_automaton.isAccept(path._state)) {
- if (path._fstNode.isFinal()) {
- // For final nodes, we need to combine the accumulated output with
the final output
- BytesRef finalOutput = path._fstNode.nextFinalOutput();
- BytesRef completeOutput;
- if (finalOutput != null && finalOutput.length > 0) {
- // Combine accumulated output with final output
- completeOutput = _ifst.outputs.add(path._output, finalOutput);
- } else {
- // Use the accumulated output if no final output
- completeOutput = path._output;
- }
- // Create a new path with the complete output
- endNodes.add(new Path<>(path._state, path._fstNode, completeOutput,
path._input));
+ // If automaton is in accept state and the fstNode is final (i.e. end
node) then emit the matched values.
+ if (_automaton.isAccept(path._state) && path._fstNode.isFinal()) {
+ // For final nodes, we need to combine the accumulated output with the
final output
+ BytesRef finalOutput = path._fstNode.nextFinalOutput();
+ BytesRef completeOutput;
+ if (finalOutput != null && finalOutput.length > 0) {
+ // Combine accumulated output with final output
+ completeOutput = _ifst.outputs.add(path._output, finalOutput);
+ } else {
+ // Use the accumulated output if no final output
+ completeOutput = path._output;
+ }
+ // Deserialize BytesRef to the list of matched dict ids
+ for (Integer value :
IFSTBuilder.deserializeBytesRefToIntegerList(completeOutput)) {
+ dictIdConsumer.accept(value);
}
}
// Gather next set of transitions on automaton and find target nodes in
FST.
- IntsRefBuilder currentInput = path._input;
int count = _automaton.initTransition(path._state, t);
for (int i = 0; i < count; i++) {
_automaton.getNextTransition(t);
- final int min = t.min;
- final int max = t.max;
+ int min = t.min;
+ int max = t.max;
if (min == max) {
- final FST.Arc<BytesRef> nextArc = _ifst.findTargetArc(t.min,
path._fstNode, scratchArc, fstReader);
+ FST.Arc<BytesRef> nextArc = _ifst.findTargetArc(t.min,
path._fstNode, scratchArc, fstReader);
if (nextArc != null) {
- final IntsRefBuilder newInput = new IntsRefBuilder();
- newInput.copyInts(currentInput.get());
- newInput.append(t.min);
- queue.add(new Path<BytesRef>(t.dest, new
FST.Arc<BytesRef>().copyFrom(nextArc),
- _ifst.outputs.add(path._output, nextArc.output()), newInput));
+ stack.add(new Path<>(t.dest, new
FST.Arc<BytesRef>().copyFrom(nextArc),
+ _ifst.outputs.add(path._output, nextArc.output())));
}
} else {
- FST.Arc<BytesRef> nextArc =
- org.apache.lucene.util.fst.Util.readCeilArc(min, _ifst,
path._fstNode, scratchArc, fstReader);
+ FST.Arc<BytesRef> nextArc = Util.readCeilArc(min, _ifst,
path._fstNode, scratchArc, fstReader);
while (nextArc != null && nextArc.label() <= max) {
- final IntsRefBuilder newInput = new IntsRefBuilder();
- newInput.copyInts(currentInput.get());
- newInput.append(nextArc.label());
- queue.add(new Path<>(t.dest, new
FST.Arc<BytesRef>().copyFrom(nextArc),
- _ifst.outputs.add(path._output, nextArc.output()), newInput));
+ stack.add(new Path<>(t.dest, new
FST.Arc<BytesRef>().copyFrom(nextArc),
+ _ifst.outputs.add(path._output, nextArc.output())));
nextArc = nextArc.isLast() ? null : _ifst.readNextRealArc(nextArc,
fstReader);
}
}
}
}
-
- // From the result set of matched entries gather the values stored and
return.
- ArrayList<Long> matchedIds = new ArrayList<>();
- for (Path<BytesRef> path : endNodes) {
- // Deserialize BytesRef to List<Integer> and convert to List<Long>
- List<Integer> intValues =
IFSTBuilder.deserializeBytesRefToIntegerList(path._output);
- for (Integer value : intValues) {
- matchedIds.add(value.longValue());
- }
- }
- return matchedIds;
}
public static final class Path<T> {
public final int _state;
public final FST.Arc<T> _fstNode;
public final T _output;
- public final IntsRefBuilder _input;
- public Path(int state, FST.Arc<T> fstNode, T output, IntsRefBuilder input)
{
+ public Path(int state, FST.Arc<T> fstNode, T output) {
_state = state;
_fstNode = fstNode;
_output = output;
- _input = input;
}
}
}
diff --git
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/fst/FSTBuilderTest.java
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/fst/FSTBuilderTest.java
index cedbc0c0de0..e32cbb5e42e 100644
---
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/fst/FSTBuilderTest.java
+++
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/fst/FSTBuilderTest.java
@@ -29,13 +29,17 @@ import org.apache.lucene.util.fst.FST;
import org.apache.pinot.segment.local.PinotBuffersAfterMethodCheckRule;
import
org.apache.pinot.segment.local.segment.index.readers.LuceneFSTIndexReader;
import org.apache.pinot.segment.spi.memory.PinotDataBuffer;
+import org.apache.pinot.spi.exception.EarlyTerminationException;
+import org.apache.pinot.spi.query.QueryThreadContext;
import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
import org.testng.Assert;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertThrows;
import static org.testng.Assert.assertTrue;
@@ -88,6 +92,42 @@ public class FSTBuilderTest implements
PinotBuffersAfterMethodCheckRule {
result = reader.getDictIds(".*world");
assertEquals(result.getCardinality(), 1);
assertTrue(result.contains(12));
+
+ result = reader.getDictIds(".*");
+ assertEquals(result.getCardinality(), 3);
+ assertTrue(result.contains(12));
+ assertTrue(result.contains(21));
+ assertTrue(result.contains(123));
+ }
+ }
+
+ @Test
+ public void testRegexMatchHonorsTermination()
+ throws IOException {
+ SortedMap<String, Integer> x = new TreeMap<>();
+ for (int i = 0; i < 1000; i++) {
+ x.put(String.format("key-%06d", i), i);
+ }
+ FST<Long> fst = FSTBuilder.buildFST(x);
+ File outputFile = new File(TEMP_DIR, "test_termination.lucene");
+ try (FileOutputStream outputStream = new FileOutputStream(outputFile);
+ OutputStreamDataOutput dataOutput = new
OutputStreamDataOutput(outputStream)) {
+ fst.save(dataOutput, dataOutput);
+ }
+
+ try (PinotDataBuffer dataBuffer =
PinotDataBuffer.loadBigEndianFile(outputFile);
+ LuceneFSTIndexReader reader = new LuceneFSTIndexReader(dataBuffer);
+ QueryThreadContext ignore = QueryThreadContext.openForSseTest()) {
+ Thread.currentThread().interrupt();
+ assertThrows(EarlyTerminationException.class,
+ () -> RegexpMatcher.regexMatch(".*", fst, new
MutableRoaringBitmap()::add));
+
+ // The reader should propagate the termination exception as-is instead
of wrapping it
+ Thread.currentThread().interrupt();
+ assertThrows(EarlyTerminationException.class, () ->
reader.getDictIds(".*"));
+ } finally {
+ // Clear the interrupt flag in case the matcher did not consume it
+ Thread.interrupted();
}
}
}
diff --git
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/fst/IFSTBuilderTest.java
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/fst/IFSTBuilderTest.java
index 5e99a978113..9a02a5df456 100644
---
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/fst/IFSTBuilderTest.java
+++
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/utils/fst/IFSTBuilderTest.java
@@ -32,13 +32,17 @@ import org.apache.lucene.util.fst.FST;
import org.apache.pinot.segment.local.PinotBuffersAfterMethodCheckRule;
import
org.apache.pinot.segment.local.segment.index.readers.LuceneIFSTIndexReader;
import org.apache.pinot.segment.spi.memory.PinotDataBuffer;
+import org.apache.pinot.spi.exception.EarlyTerminationException;
+import org.apache.pinot.spi.query.QueryThreadContext;
import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
import org.testng.Assert;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertThrows;
import static org.testng.Assert.assertTrue;
@@ -105,6 +109,40 @@ public class IFSTBuilderTest implements
PinotBuffersAfterMethodCheckRule {
assertEquals(result.getCardinality(), 2); // Should still match both
assertTrue(result.contains(12));
assertTrue(result.contains(13));
+
+ // Test full enumeration
+ result = reader.getDictIds(".*");
+ assertEquals(result.getCardinality(), 6); // Should match all entries
+ }
+ }
+
+ @Test
+ public void testRegexMatchHonorsTermination()
+ throws IOException {
+ SortedMap<String, Integer> x = new TreeMap<>();
+ for (int i = 0; i < 1000; i++) {
+ x.put(String.format("key-%06d", i), i);
+ }
+ FST<BytesRef> ifst = IFSTBuilder.buildIFST(x);
+ File outputFile = new File(TEMP_DIR,
"test_termination_case_insensitive.lucene");
+ try (FileOutputStream outputStream = new FileOutputStream(outputFile);
+ OutputStreamDataOutput dataOutput = new
OutputStreamDataOutput(outputStream)) {
+ ifst.save(dataOutput, dataOutput);
+ }
+
+ try (PinotDataBuffer dataBuffer =
PinotDataBuffer.loadBigEndianFile(outputFile);
+ LuceneIFSTIndexReader reader = new LuceneIFSTIndexReader(dataBuffer);
+ QueryThreadContext ignore = QueryThreadContext.openForSseTest()) {
+ Thread.currentThread().interrupt();
+ assertThrows(EarlyTerminationException.class,
+ () -> RegexpMatcherCaseInsensitive.regexMatch(".*", ifst, new
MutableRoaringBitmap()::add));
+
+ // The reader should propagate the termination exception as-is instead
of wrapping it
+ Thread.currentThread().interrupt();
+ assertThrows(EarlyTerminationException.class, () ->
reader.getDictIds(".*"));
+ } finally {
+ // Clear the interrupt flag in case the matcher did not consume it
+ Thread.interrupted();
}
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]