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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,15 @@ final class BitStateMatcher extends ReggieMatcher {
private final LazyDFACache rejectDfa;
private final NfaStep rejectStep;

// Reachability anchor for the bundle this matcher's reject cache came from (null when the
// bundle was built matcher-privately). RuntimeCompiler soft-holds the shared bundle in its
// cache entry; holding it here too means the SoftReference cannot be cleared while any
// matcher built from that bundle is alive, so a later compile() reuses the same warmed
// reject cache instead of allocating a duplicate over a cache live matchers already pin.
// Never read — kept purely for GC reachability.
@SuppressWarnings("unused")
private final RejectDfaFactory.Bundle sourceBundle;

// Scratch output for localizeForFind(), reused across calls to avoid a two-int allocation per
// find()/findFrom()/findMatchFrom() call — safe because a single matcher instance is never
// shared across threads or concurrent calls (see class-level thread-safety contract).
Expand Down Expand Up @@ -170,7 +179,8 @@ final class BitStateMatcher extends ReggieMatcher {
/**
* @param sharedRejectBundle NFA-derived reject-DFA bundle shared across matchers of the same NFA
* ({@link RejectDfaFactory.Bundle} is immutable and its {@link LazyDFACache} is safe for
* concurrent population); {@code null} builds a matcher-private bundle.
* concurrent population); {@code null} builds a matcher-private bundle; {@link
* RejectDfaFactory#NONE} records a known-ineligible NFA and skips construction entirely.
*/
BitStateMatcher(
NFA nfa,
Expand Down Expand Up @@ -272,6 +282,7 @@ final class BitStateMatcher extends ReggieMatcher {

RejectDfaFactory.Bundle rejectBundle =
sharedRejectBundle == null ? RejectDfaFactory.build(nfa) : sharedRejectBundle;
this.sourceBundle = sharedRejectBundle;
this.rejectDfa = rejectBundle == null ? null : rejectBundle.dfa;
this.rejectStep = rejectBundle == null ? null : rejectBundle.step;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ public final class PikeVMMatcher extends ReggieMatcher {
private final LazyDFACache findDfa;
private final NfaStep findStep;
private final boolean findCanMatchEmpty;

// Reachability anchor for the bundle this matcher's caches came from (null when the caches
// were built matcher-privately). RuntimeCompiler soft-holds the shared bundle in its cache
// entry; holding it here too means the SoftReference cannot be cleared while any matcher
// built from that bundle is alive, so a later compile() reuses the same warmed caches
// instead of allocating a duplicate bundle over caches the live matchers already pin.
// Never read — kept purely for GC reachability.
@SuppressWarnings("unused")
private final DfaBundle sourceBundle;

private int[] startClosureIds; // pos-0 closure (START/\A/^ml anchors crossed); set in ctor
private int[] reinjectClosureIds; // mid-line pos>0 closure (START/\A/^ml blocked); set in ctor
// After-newline reinject closure: START/\A blocked, but START_MULTILINE crossed (^ fires after
Expand Down Expand Up @@ -203,6 +213,7 @@ public PikeVMMatcher(NFA nfa, String pattern, DfaBundle bundle) {
}

this.nfa = nfa;
this.sourceBundle = bundle;
this.groupCount = nfa.getGroupCount();
this.stateCount = nfa.getStates().size();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ private Bundle(LazyDFACache dfa, NfaStep step) {
}
}

/**
* Sentinel for NFAs already determined to be reject-DFA ineligible ({@link #build} returned null:
* assertions/backrefs, or the over-approximation matches empty). Passing this instead of {@code
* null} to {@code BitStateMatcher} skips the matcher-private {@code build(nfa)} retry — a
* non-null bundle with null fields resolves to {@code rejectDfa == null}, exactly as a null
* result does. Strongly held, so a SoftReference carrying it is never cleared.
*/
static final Bundle NONE = new Bundle(null, null);

static Bundle build(NFA nfa) {
for (NFA.NFAState s : nfa.getStates()) {
if (s.assertionType != null || s.backrefCheck != null) return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
import com.datadoghq.reggie.codegen.parsing.RegexParser;
import java.io.PrintWriter;
import java.lang.invoke.MethodHandles;
import java.lang.ref.SoftReference;
import java.lang.reflect.Constructor;
import java.util.Collections;
import java.util.List;
Expand Down Expand Up @@ -154,20 +155,29 @@ ReggieMatcher newInstance(String pattern) {
private static final class PikeVMEntry {
final NFA nfa;
final Map<String, Integer> nameMap;
// NFA-derived DFA setup shared across every matcher this entry produces. Built once on first
// use; the build is deterministic, so a concurrent first-use race yields equivalent bundles.
private volatile PikeVMMatcher.DfaBundle dfaBundle;
// NFA-derived DFA setup shared across every matcher this entry produces. Held through a
// SoftReference: each DfaBundle eagerly allocates fixed-capacity LazyDFACache backing arrays
// (~36 KiB per cache on compressed-oops heaps, ~70 KiB without; several MiB once populated), so
// keeping one strongly per cached pattern
// would let a high-cardinality pattern workload exhaust the heap through the otherwise
// long-lived PIKEVM_NFA_CACHE. Soft retention keeps the sharing benefit while memory is
// plentiful and lets the GC evict under pressure; the build is deterministic (a pure function
// of the NFA), so a rebuilt or concurrently duplicated bundle is equivalent. Matchers copy the
// bundle's fields into their own final state (see PikeVMMatcher's constructor), so eviction
// never affects live matcher instances.
private volatile SoftReference<PikeVMMatcher.DfaBundle> dfaBundle;
Comment thread
jbachorik marked this conversation as resolved.

PikeVMEntry(NFA nfa, Map<String, Integer> nameMap) {
this.nfa = nfa;
this.nameMap = nameMap;
}

ReggieMatcher newMatcher(String pattern) {
PikeVMMatcher.DfaBundle bundle = dfaBundle;
SoftReference<PikeVMMatcher.DfaBundle> ref = dfaBundle;
PikeVMMatcher.DfaBundle bundle = ref != null ? ref.get() : null;
if (bundle == null) {
bundle = new PikeVMMatcher.DfaBundle(nfa);
dfaBundle = bundle;
dfaBundle = new SoftReference<>(bundle);
}
ReggieMatcher m = new PikeVMMatcher(nfa, pattern, bundle);
if (!nameMap.isEmpty()) {
Expand Down Expand Up @@ -232,6 +242,12 @@ private static final class HybridEntry {
nfaHalfClass; // OPTIMIZED_NFA half; null for PikeVM/BitState
final boolean pruned; // dfa-half carries leftmost-first pruning
final Map<String, Integer> nameMap;
// NFA-half bundles, soft-held and shared across matchers this entry produces — the same
// bounded-retention contract as PikeVMEntry/BitStateEntry (fixed-capacity LazyDFACache
// arrays; deterministic rebuild after eviction). Without this, every compile() of a hybrid
// pattern allocates a private bundle for its PikeVM/BitState half.
private volatile SoftReference<PikeVMMatcher.DfaBundle> pikeVmBundle;
private volatile SoftReference<RejectDfaFactory.Bundle> bitStateBundle;

HybridEntry(
ReggieMatcher dfaMatcher,
Expand All @@ -252,7 +268,7 @@ ReggieMatcher newMatcher(String pattern) throws Exception {
ReggieMatcher nfaMatcher =
nfaHalfClass != null
? nfaHalfClass.getDeclaredConstructor(String.class).newInstance(pattern)
: newHybridNfaHalf(captureNfa, originalResult, pattern);
: newHybridNfaHalf(pattern);
ReggieMatcher m =
dfaMatcher == null
? nfaMatcher
Expand All @@ -265,6 +281,44 @@ ReggieMatcher newMatcher(String pattern) throws Exception {
}
return m;
}

PikeVMMatcher.DfaBundle pikeVmBundle() {
SoftReference<PikeVMMatcher.DfaBundle> ref = pikeVmBundle;
PikeVMMatcher.DfaBundle bundle = ref != null ? ref.get() : null;
if (bundle == null) {
bundle = new PikeVMMatcher.DfaBundle(captureNfa);
pikeVmBundle = new SoftReference<>(bundle);
}
return bundle;
}

/**
* The shared reject bundle for the capture NFA, or {@link RejectDfaFactory#NONE} when it is
* ineligible (so {@link BitStateMatcher} skips its matcher-private build retry). Never null.
*/
RejectDfaFactory.Bundle bitStateBundle() {
SoftReference<RejectDfaFactory.Bundle> ref = bitStateBundle;
RejectDfaFactory.Bundle bundle = ref != null ? ref.get() : null;
if (bundle == null) {
bundle = RejectDfaFactory.build(captureNfa);
if (bundle == null) {
bundle = RejectDfaFactory.NONE;
}
bitStateBundle = new SoftReference<>(bundle);
}
return bundle;
}

/** Mirrors the standalone engine choice for the NFA half (BitState for BITSTATE originals). */
private ReggieMatcher newHybridNfaHalf(String pattern) throws Exception {
if (originalResult.strategy == PatternAnalyzer.MatchingStrategy.BITSTATE_CAPTURE) {
ReggieMatcher laurikari =
LaurikariDfaSupport.tryCreate(
captureNfa, pattern, captureNfa.getGroupCount(), originalResult.usePosixLastMatch);
return new BitStateMatcher(captureNfa, pattern, laurikari, bitStateBundle());
}
return new PikeVMMatcher(captureNfa, pattern, pikeVmBundle());
}
}

// Hybrid patterns: entry cache so compile() can return a fresh matcher per call. Hybrids carry
Expand All @@ -283,21 +337,33 @@ private static final class BitStateEntry {
final NFA nfa;
final Map<String, Integer> nameMap;
final boolean usePosixLastMatch;
// NFA-derived reject-DFA bundle shared across every matcher this entry produces. Built once
// on first use; the build is deterministic, so a concurrent first-use race is benign.
private volatile RejectDfaFactory.Bundle rejectBundle;
// NFA-derived reject-DFA bundle shared across every matcher this entry produces. Soft-held for
// the same reason PikeVMEntry soft-holds its DfaBundle (fixed-capacity LazyDFACache arrays);
// the build is deterministic, so a rebuilt or concurrently duplicated bundle is equivalent.
// When the NFA is ineligible (see RejectDfaFactory.NONE), the sentinel is stored instead of a
// real bundle: it is strongly held, so the SoftReference never clears and the O(states)
// ineligibility scan runs exactly once per entry instead of once per matcher.
private volatile SoftReference<RejectDfaFactory.Bundle> rejectBundle;

BitStateEntry(NFA nfa, Map<String, Integer> nameMap, boolean usePosixLastMatch) {
this.nfa = nfa;
this.nameMap = nameMap;
this.usePosixLastMatch = usePosixLastMatch;
}

/**
* The shared reject bundle, or {@link RejectDfaFactory#NONE} when the NFA is ineligible (so
* {@link BitStateMatcher} skips its matcher-private build retry). Never null.
*/
RejectDfaFactory.Bundle rejectBundle() {
RejectDfaFactory.Bundle bundle = rejectBundle;
SoftReference<RejectDfaFactory.Bundle> ref = rejectBundle;
RejectDfaFactory.Bundle bundle = ref != null ? ref.get() : null;
if (bundle == null) {
bundle = RejectDfaFactory.build(nfa);
rejectBundle = bundle;
if (bundle == null) {
Comment thread
jbachorik marked this conversation as resolved.
bundle = RejectDfaFactory.NONE;
}
rejectBundle = new SoftReference<>(bundle);
}
return bundle;
}
Expand Down Expand Up @@ -2076,17 +2142,6 @@ private static String countedLoopUnsupportedFeature(NFA nfa) {
return null;
}

private static ReggieMatcher newHybridNfaHalf(
NFA nfa, PatternAnalyzer.MatchingStrategyResult originalResult, String pattern) {
if (originalResult.strategy == PatternAnalyzer.MatchingStrategy.BITSTATE_CAPTURE) {
ReggieMatcher laurikari =
LaurikariDfaSupport.tryCreate(
nfa, pattern, nfa.getGroupCount(), originalResult.usePosixLastMatch);
return new BitStateMatcher(nfa, pattern, laurikari);
}
return new PikeVMMatcher(nfa, pattern);
}

/** Instantiate a matcher from bytecode. The pattern string is passed to the constructor. */
private static ReggieMatcher instantiateMatcher(byte[] bytecode, String pattern)
throws Exception {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,20 @@
*/
package com.datadoghq.reggie.runtime;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;

import com.datadoghq.reggie.Reggie;
import com.datadoghq.reggie.codegen.ast.RegexNode;
import com.datadoghq.reggie.codegen.automaton.NFA;
import com.datadoghq.reggie.codegen.automaton.ThompsonBuilder;
import com.datadoghq.reggie.codegen.parsing.RegexParser;
import java.lang.ref.SoftReference;
import java.lang.reflect.Field;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
Expand All @@ -37,6 +42,10 @@
* one set of lazily-materialized DFA caches (the per-op matcher construction otherwise recomputes
* the warm DFA on every compile — the dominant cost when a service compiles per operation), and
* concurrent use of a shared cache must stay correct.
*
* <p>The shared bundles are held through {@link SoftReference} (bounded retention under heap
* pressure — see RuntimeCompiler's cache entries); the eviction tests at the bottom pin the
* rebuild-on-clear path that makes that sound.
*/
class SharedDfaBundleTest {

Expand Down Expand Up @@ -98,10 +107,38 @@ void matchersOfOneBundleShareDfaCaches() throws Exception {
PikeVMMatcher m2 = new PikeVMMatcher(nfa, pattern, bundle);
assertSame(bundle.findDfa, findDfa(m1));
assertSame(bundle.findDfa, findDfa(m2));
// Review #140 r4165029351: matchers must retain the bundle itself, so the entry's
// SoftReference cannot be GC-cleared while live matchers still pin the bundle's caches.
assertSame(bundle, sourceBundle(m1));
assertSame(bundle, sourceBundle(m2));
assertTrue(m1.matches("host:abc"));
assertTrue(m2.matches(",host:9"));
}

/** The matcher-held bundle reachability anchor (PikeVMMatcher.sourceBundle). */
private static PikeVMMatcher.DfaBundle sourceBundle(PikeVMMatcher m) throws Exception {
Field f = PikeVMMatcher.class.getDeclaredField("sourceBundle");
f.setAccessible(true);
return (PikeVMMatcher.DfaBundle) f.get(m);
}

/**
* {@link RejectDfaFactory#NONE} marks a known-ineligible NFA: BitStateMatcher must skip its
* matcher-private build retry (review #140 r4165029362) and run without a reject DFA.
*/
@Test
void noneSentinelSkipsPrivateRejectBuild() throws Exception {
String pattern = "\\bhost:[0-9]+";
NFA nfa = nfa(pattern);
BitStateMatcher m = new BitStateMatcher(nfa, pattern, null, RejectDfaFactory.NONE);
Field f = BitStateMatcher.class.getDeclaredField("rejectDfa");
f.setAccessible(true);
assertNull(f.get(m), "NONE must resolve to no reject DFA, not a private rebuild");
assertTrue(m.matches("host:123"));
assertFalse(m.matches("host:abc"));
assertFalse(m.matches("noservicehere"));
}

@Test
void matchersWithoutBundleBuildPrivateCaches() throws Exception {
String pattern = "(?:^|,)[hH][oO][sS][tT]:[a-zA-Z_0-9]+";
Expand Down Expand Up @@ -174,4 +211,73 @@ void concurrentUseOfSharedCacheIsCorrect() throws Exception {
pool.shutdownNow();
}
}

// ── Eviction: a GC-cleared SoftReference in a cache entry must yield a correct rebuilt bundle.
// \b(a?)+x routes to PIKEVM_CAPTURE and \b(a)+x to BITSTATE_CAPTURE (word boundary skips the
// hybrid; bitstate eligibility splits the two — verified via RuntimeCompiler.describeRouting).
private static Object cacheEntry(String cacheField, String pattern) throws Exception {
Field f = RuntimeCompiler.class.getDeclaredField(cacheField);
f.setAccessible(true);
@SuppressWarnings("unchecked")
Map<Object, Object> cache = (Map<Object, Object>) f.get(null);
Object entry = cache.get(pattern);
assertNotNull(entry, "compile must register the pattern in " + cacheField);
return entry;
}

private static void evictBundle(Object entry, String fieldName) throws Exception {
Field f = entry.getClass().getDeclaredField(fieldName);
f.setAccessible(true);
f.set(entry, new SoftReference<>(null));
}

@SuppressWarnings("unchecked")
private static <T> T referencedBundle(Object entry, String fieldName) throws Exception {
Field f = entry.getClass().getDeclaredField(fieldName);
f.setAccessible(true);
return ((SoftReference<T>) f.get(entry)).get();
}

@Test
void evictedPikeVmBundleIsRebuiltCorrectly() throws Exception {
String pattern = "\\b(a?)+x";
assertTrue(Reggie.compile(pattern).matches("ax")); // builds the entry + bundle
Object entry = cacheEntry("PIKEVM_NFA_CACHE", pattern);
PikeVMMatcher.DfaBundle original = referencedBundle(entry, "dfaBundle");
assertNotNull(original, "first compile must have built the bundle strongly");

evictBundle(entry, "dfaBundle"); // simulate GC having cleared the SoftReference

ReggieMatcher rebuilt = Reggie.compile(pattern);
PikeVMMatcher.DfaBundle second = referencedBundle(entry, "dfaBundle");
assertNotNull(second, "evicted entry must rebuild the bundle, not stay null");
assertTrue(second != original, "rebuilt bundle must not be the evicted instance");
assertTrue(rebuilt.matches("ax"));
assertTrue(rebuilt.matches("x")); // (a?)+ can iterate on the empty string
assertTrue(rebuilt.find(" ax")); // space->a is a word boundary
assertFalse(rebuilt.find("yax")); // no word boundary before 'a' mid-word
assertFalse(rebuilt.matches("a"));
}

@Test
void evictedBitStateRejectBundleIsRebuiltCorrectly() throws Exception {
String pattern = "\\b(a)+x";
assertTrue(Reggie.compile(pattern).matches("ax")); // builds the entry + reject bundle
Object entry = cacheEntry("BITSTATE_NFA_CACHE", pattern);
RejectDfaFactory.Bundle original = referencedBundle(entry, "rejectBundle");
assertNotNull(original, "first compile must have built the reject bundle strongly");

evictBundle(entry, "rejectBundle"); // simulate GC having cleared the SoftReference

ReggieMatcher rebuilt = Reggie.compile(pattern);
RejectDfaFactory.Bundle second = referencedBundle(entry, "rejectBundle");
assertNotNull(second, "evicted entry must rebuild the reject bundle, not stay null");
assertTrue(second != original, "rebuilt bundle must not be the evicted instance");
assertTrue(rebuilt.matches("ax"));
assertTrue(rebuilt.matches("aaax"));
assertTrue(rebuilt.find(" aax")); // space->a is a word boundary
assertFalse(rebuilt.find("yaax")); // no word boundary before 'a' mid-word
assertFalse(rebuilt.matches("x"));
assertFalse(rebuilt.matches("a"));
}
}
Loading