From f30b1ea4f6147dac5c19f6d9617bbcd6e221a16d Mon Sep 17 00:00:00 2001 From: "yuyu.zhao" Date: Sun, 28 Sep 2025 16:09:05 +0800 Subject: [PATCH 1/5] add ScodePatternFilter --- pom.xml | 5 + .../gliwka/hyperscan/util/ExpressionUtil.java | 44 ++++ .../gliwka/hyperscan/util/PatternFilter.java | 35 +--- .../hyperscan/util/PatternFilterCleaner.java | 27 +++ .../hyperscan/util/ScopedPatternFilter.java | 75 +++++++ .../util/ScopedPatternFilterFactory.java | 181 ++++++++++++++++ .../util/ScopedPatternFilterImpl.java | 111 ++++++++++ .../util/ScopedPatternFilterProxy.java | 23 +++ .../util/PatternFilterCleanerTest.java | 74 +++++++ .../util/ScopedPatternFilterFactoryTest.java | 193 ++++++++++++++++++ .../util/ScopedPatternFilterImplTest.java | 81 ++++++++ .../util/ScopedPatternFilterProxy.java | 98 +++++++++ 12 files changed, 913 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/gliwka/hyperscan/util/ExpressionUtil.java create mode 100644 src/main/java/com/gliwka/hyperscan/util/PatternFilterCleaner.java create mode 100644 src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilter.java create mode 100644 src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactory.java create mode 100644 src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterImpl.java create mode 100644 src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterProxy.java create mode 100644 src/test/java/com/gliwka/hyperscan/util/PatternFilterCleanerTest.java create mode 100644 src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactoryTest.java create mode 100644 src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterImplTest.java create mode 100644 src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterProxy.java diff --git a/pom.xml b/pom.xml index f067b98..1201192 100644 --- a/pom.xml +++ b/pom.xml @@ -242,6 +242,11 @@ javacpp 1.5.11 + + com.google.guava + guava + 33.5.0-jre + org.junit.jupiter junit-jupiter-api diff --git a/src/main/java/com/gliwka/hyperscan/util/ExpressionUtil.java b/src/main/java/com/gliwka/hyperscan/util/ExpressionUtil.java new file mode 100644 index 0000000..6decb28 --- /dev/null +++ b/src/main/java/com/gliwka/hyperscan/util/ExpressionUtil.java @@ -0,0 +1,44 @@ +package com.gliwka.hyperscan.util; + +import com.gliwka.hyperscan.wrapper.Expression; +import com.gliwka.hyperscan.wrapper.ExpressionFlag; + +import java.util.EnumSet; +import java.util.regex.Pattern; + +final class ExpressionUtil { + + private ExpressionUtil() { + + throw new IllegalStateException("Utility class"); + } + + static Expression mapToExpression(Pattern pattern, int id) { + EnumSet flags = EnumSet.of(ExpressionFlag.UTF8, ExpressionFlag.PREFILTER, ExpressionFlag.ALLOWEMPTY, ExpressionFlag.SINGLEMATCH); + + if (hasFlag(pattern, Pattern.CASE_INSENSITIVE)) { + flags.add(ExpressionFlag.CASELESS); + } + + if (hasFlag(pattern, Pattern.MULTILINE)) { + flags.add(ExpressionFlag.MULTILINE); + } + + if (hasFlag(pattern, Pattern.DOTALL)) { + flags.add(ExpressionFlag.DOTALL); + } + + Expression expression = new Expression(pattern.pattern(), flags, id); + + if (!expression.validate().isValid()) { + return null; + } + + return expression; + } + + static boolean hasFlag(Pattern pattern, int flag) { + return (pattern.flags() & flag) == flag; + } + +} diff --git a/src/main/java/com/gliwka/hyperscan/util/PatternFilter.java b/src/main/java/com/gliwka/hyperscan/util/PatternFilter.java index 50f6047..5c08a45 100644 --- a/src/main/java/com/gliwka/hyperscan/util/PatternFilter.java +++ b/src/main/java/com/gliwka/hyperscan/util/PatternFilter.java @@ -52,7 +52,7 @@ public PatternFilter(List patterns) throws CompileErrorException { int id = 0; for(Pattern pattern : patterns) { - Expression expression = mapToExpression(pattern, id); + Expression expression = ExpressionUtil.mapToExpression(pattern, id); if(expression == null) { //can't be compiled to expression -> not filterable @@ -91,39 +91,6 @@ public List filter(String input) { return matchedMatchers; } - private Expression mapToExpression(Pattern pattern, int id) { - EnumSet flags = EnumSet.of( - ExpressionFlag.UTF8, - ExpressionFlag.PREFILTER, - ExpressionFlag.ALLOWEMPTY, - ExpressionFlag.SINGLEMATCH - ); - - if(hasFlag(pattern, Pattern.CASE_INSENSITIVE)) { - flags.add(ExpressionFlag.CASELESS); - } - - if(hasFlag(pattern, Pattern.MULTILINE)) { - flags.add(ExpressionFlag.MULTILINE); - } - - if(hasFlag(pattern, Pattern.DOTALL)) { - flags.add(ExpressionFlag.DOTALL); - } - - Expression expression = new Expression(pattern.pattern(), flags, id); - - if(!expression.validate().isValid()) { - return null; - } - - return expression; - } - - private boolean hasFlag(Pattern pattern, int flag) { - return (pattern.flags() & flag) == flag; - } - @Override public void close() throws IOException { scanner.close(); diff --git a/src/main/java/com/gliwka/hyperscan/util/PatternFilterCleaner.java b/src/main/java/com/gliwka/hyperscan/util/PatternFilterCleaner.java new file mode 100644 index 0000000..8a599f7 --- /dev/null +++ b/src/main/java/com/gliwka/hyperscan/util/PatternFilterCleaner.java @@ -0,0 +1,27 @@ +package com.gliwka.hyperscan.util; + + +import java.lang.ref.PhantomReference; +import java.lang.ref.ReferenceQueue; + +final class PatternFilterCleaner extends PhantomReference> { + + private final Runnable thunk; + + PatternFilterCleaner( + ScopedPatternFilter referent, ReferenceQueue> q) { + super(referent, q); + this.thunk = referent.getCloseAction(); + } + + public void clean() { + if (thunk != null) { + try { + thunk.run(); + } catch (Exception e) { + // Swallow exceptions to avoid disrupting the cleaner thread + } + } + } +} + diff --git a/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilter.java b/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilter.java new file mode 100644 index 0000000..66e2885 --- /dev/null +++ b/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilter.java @@ -0,0 +1,75 @@ +package com.gliwka.hyperscan.util; + +import java.io.Closeable; +import java.util.List; +import java.util.function.Function; + +/** + * Represents a pre-compiled filter for a set of regular expression patterns, optimized + * for high-performance scanning using the Hyperscan library. + * + *

This interface is designed as an optimization layer to quickly eliminate non-matching + * patterns from a large collection before performing more expensive, full regex matches. + * It functions as a "pre-filter" or "candidate selection" tool. + * + *

Usage Pattern and Contract

+ * The core method, {@link #filter(String)}, takes an input string and returns a list + * of "potential matches". This list includes: + *
    + *
  1. All patterns that were successfully matched by the high-performance Hyperscan engine.
  2. + *
  3. All patterns that could not be compiled by Hyperscan (e.g., those containing + * lookarounds or other unsupported features). These are always included as they + * cannot be definitively ruled out by this filter.
  4. + *
+ * + *

Crucially, the caller is responsible for performing a final, precise match on the + * returned candidates using a standard regex engine like {@link java.util.regex.Matcher}. + * + *

Resource Management

+ * As this interface extends {@link Closeable}, it holds native resources (a compiled + * Hyperscan database and scratch space) that must be released. It is intended for use + * within a try-with-resources statement to ensure proper cleanup. + * + *

Example usage: + *

{@code
+ * ScopedPatternFilterFactory factory = ...;
+ *
+ * try (ScopedPatternFilter filter = factory.get()) {
+ *     List potentialMatches = filter.filter("Some input string to test");
+ *     for (Pattern candidate : potentialMatches) {
+ *         if (candidate.matcher("Some input string to test").find()) {
+ *             // This is a confirmed match.
+ *         }
+ *     }
+ * }
+ * }
+ * + * @param The type of the original object associated with a pattern. This allows the + * filter to be used with custom classes, not just {@link java.util.regex.Pattern} objects. + * @see ScopedPatternFilterFactory + * @see java.util.regex.Pattern + */ +public interface ScopedPatternFilter extends Closeable, Function> { + + /** + * Filters the provided input and returns a list of potentially matching patterns. This method + * uses the high-performance Hyperscan library for compatible patterns. Any patterns that could + * not be compiled for Hyperscan are always included in the returned list, as they are considered + * potential matches that require further checking. + * + * @param input Input to be filtered + * @return A list of patterns that either matched via Hyperscan or could not be filtered by it. + */ + List filter(String input); + + @Override + default List apply(String s) { + return filter(s); + } + + default Runnable getCloseAction() { + return () -> { + }; + } +} + diff --git a/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactory.java b/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactory.java new file mode 100644 index 0000000..800aec4 --- /dev/null +++ b/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactory.java @@ -0,0 +1,181 @@ +package com.gliwka.hyperscan.util; + +import com.gliwka.hyperscan.wrapper.CompileErrorException; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.MapMaker; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import lombok.AccessLevel; +import lombok.Getter; + +import java.io.Closeable; +import java.io.IOException; +import java.lang.ref.ReferenceQueue; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.regex.Pattern; + +/** + * A factory for creating and managing thread-local instances of {@link ScopedPatternFilter}. + * + *

This class is the primary entry point for using the Hyperscan filtering mechanism. + * It is designed to be created once and shared across an application. It addresses two + * key challenges: + *

    + *
  1. Performance: The high cost of compiling Hyperscan databases is amortized + * by creating a single, thread-local filter instance that is reused for all + * subsequent operations on that thread.
  2. + *
  3. Thread Safety: Hyperscan's scanning context (scratch space) is not + * thread-safe. This factory ensures each thread gets its own isolated instance, + * preventing concurrent access issues.
  4. + *
+ * + *

Usage Pattern

+ * A single factory instance should be created and retained for the lifetime of the + * application. In methods that require filtering, {@link #get()} should be called within + * a try-with-resources block to obtain a thread-safe filter instance. + * + *

Example usage: + *

{@code
+ * // In application initialization:
+ * List myPatterns = loadPatterns();
+ * ScopedPatternFilterFactory filterFactory = ScopedPatternFilterFactory.ofPatterns(myPatterns);
+ *
+ * // In a service method (called by multiple threads):
+ * public void processText(String text) {
+ *     try (ScopedPatternFilter filter = filterFactory.get()) {
+ *         List candidates = filter.filter(text);
+ *         // ... perform final matching on candidates ...
+ *     }
+ * }
+ *
+ * // In application shutdown:
+ * filterFactory.close();
+ * }
+ * + *

Lifecycle and Resource Management

+ * The factory manages a complex lifecycle: + *
    + *
  • Thread-Local Caching: Calling {@link #get()} returns a lightweight proxy to a + * thread-local {@code ScopedPatternFilter} instance. The actual filter implementation is + * cached and reused for the lifetime of the thread. The proxy prevents callers from + * accidentally closing the shared, thread-local instance.
  • + *
  • Automatic Cleanup: The factory automatically manages the cleanup of resources + * for threads that have terminated. It uses a background cleaner thread to release the + * native Hyperscan resources associated with a dead thread, preventing memory leaks.
  • + *
  • Factory Closure: The factory itself is {@link Closeable}. When the factory is no + * longer needed (e.g., during application shutdown), its {@link #close()} method + * must be called. This will explicitly release all active filter resources it has + * created and shut down its background cleanup task. Failure to close the factory + * will result in resource leaks.
  • + *
+ * + * @param The type of the original object from which a pattern can be derived. + * @see ScopedPatternFilter + */ +public final class ScopedPatternFilterFactory implements Supplier>, Closeable { + + // A single, shared, daemon cleaner thread for all factory instances. + private static final ScheduledExecutorService CLEANER_SERVICE = Executors.newSingleThreadScheduledExecutor(new ThreadFactoryBuilder().setThreadFactory(Executors.defaultThreadFactory()).setNameFormat("ScopedPatternFilter-Shared-Cleaner-%d").setDaemon(true).build()); + + // --- Instance-specific fields --- + private final ReferenceQueue> referenceQueue = new ReferenceQueue<>(); + private final AtomicBoolean closed = new AtomicBoolean(false); + + @Getter(AccessLevel.PACKAGE) + @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") + private final Set refKeeper = ConcurrentHashMap.newKeySet(); + + @Getter(AccessLevel.PACKAGE) + private final ConcurrentMap> threadLocalFilters = new MapMaker().weakKeys().makeMap(); + + private final ScheduledFuture cleanerTaskFuture; // Handle to this instance's cleanup task. + + private final List patterns; + private final Function patternMapper; + + public ScopedPatternFilterFactory(Iterable patterns, Function patternMapper) { + Preconditions.checkNotNull(patternMapper, "patternMapper cannot be null"); + Preconditions.checkNotNull(patterns, "patterns cannot be null"); + this.patterns = ImmutableList.copyOf(patterns); + Preconditions.checkArgument(!this.patterns.isEmpty(), "patterns cannot be empty"); + this.patternMapper = patternMapper; + + // Schedule this instance's cleanup task on the shared executor. + this.cleanerTaskFuture = CLEANER_SERVICE.scheduleWithFixedDelay(this::cleanUp, 1, 1, TimeUnit.SECONDS); + } + + public static ScopedPatternFilterFactory ofPatterns(Iterable patterns) { + return new ScopedPatternFilterFactory<>(patterns, Function.identity()); + } + + // This is an instance method that knows about this instance's queue and refKeeper. + private void cleanUp() { + try { + PatternFilterCleaner ref; + while ((ref = (PatternFilterCleaner) referenceQueue.poll()) != null) { + refKeeper.remove(ref); + ref.clean(); + } + } catch (Exception e) { + // Log or handle exception + } + } + + private ScopedPatternFilter createFilter() { + Preconditions.checkState(!closed.get(), "ScopedPatternFilterFactory is closed."); + try { + ScopedPatternFilterImpl filter = new ScopedPatternFilterImpl<>(patterns, patternMapper); + // Use this instance's referenceQueue. + PatternFilterCleaner cleaner = new PatternFilterCleaner(filter, referenceQueue); + refKeeper.add(cleaner); + return filter; + } catch (CompileErrorException e) { + throw new RuntimeException("Failed to compile patterns into ScopedPatternFilter", e); + } + } + + @Override + public ScopedPatternFilter get() { + Preconditions.checkState(!closed.get(), "ScopedPatternFilterFactory is closed."); + ScopedPatternFilter filter = threadLocalFilters.computeIfAbsent(Thread.currentThread(), t -> createFilter()); + return new ScopedPatternFilterProxy<>(filter); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + + // 1. Explicitly close all still-active filter instances. + for (Map.Entry> entry : threadLocalFilters.entrySet()) { + try { + entry.getValue().close(); + } catch (IOException e) { + // Log this error. + } + } + + // 2. Clear the map to release references. + threadLocalFilters.clear(); + + // 3. Cancel this instance's scheduled cleanup task. + // Other factory instances' tasks on the shared executor are unaffected. + this.cleanerTaskFuture.cancel(false); + + // 4. Perform a final cleanup pass and clear the reference keeper. + cleanUp(); + refKeeper.clear(); + } + } +} + diff --git a/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterImpl.java b/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterImpl.java new file mode 100644 index 0000000..cb8df74 --- /dev/null +++ b/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterImpl.java @@ -0,0 +1,111 @@ +package com.gliwka.hyperscan.util; + + +import com.gliwka.hyperscan.wrapper.CompileErrorException; +import com.gliwka.hyperscan.wrapper.Database; +import com.gliwka.hyperscan.wrapper.Expression; +import com.gliwka.hyperscan.wrapper.Match; +import com.gliwka.hyperscan.wrapper.Scanner; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; +import java.util.regex.Pattern; + +final class ScopedPatternFilterImpl implements ScopedPatternFilter { + + private final AtomicBoolean closed = new AtomicBoolean(false); + private final Database database; + private final Scanner scanner; + private final List filterable; + private final List notFilterable; + + /** + * Create a pattern filter for the provided patterns + * + * @param patterns Patterns to be filtered + * @throws CompileErrorException in case the compilation of the hyperscan representation fails + */ + ScopedPatternFilterImpl(List patterns, Function patternMapper) throws CompileErrorException { + List expressions = new ArrayList<>(); + List notFilterable = new ArrayList<>(); + List filterable = new ArrayList<>(); + + for (T pattern : patterns) { + Pattern p = patternMapper.apply(pattern); + Objects.requireNonNull(p, "a patternMapper returned null for " + pattern); + Expression expression = ExpressionUtil.mapToExpression(p, filterable.size()); + + if (expression == null) { + // can't be compiled to expression -> not filterable + notFilterable.add(pattern); + } else { + expressions.add(expression); + filterable.add(pattern); + } + } + + this.database = Database.compile(expressions); + this.scanner = new Scanner(); + this.scanner.allocScratch(database); + this.filterable = ImmutableList.copyOf(filterable); + this.notFilterable = ImmutableList.copyOf(notFilterable); + } + + @SuppressWarnings("SynchronizationOnLocalVariableOrMethodParameter") + private static void close(AtomicBoolean closed, Scanner scanner, Database database) throws IOException { + if (closed.compareAndSet(false, true)) { + // Ensure scanner and database are closed in a thread-safe manner + synchronized (scanner) { + scanner.close(); + database.close(); + } + } + } + + @Override + public List filter(String input) { + Preconditions.checkNotNull(input); + Preconditions.checkState(!closed.get(), "Pattern filter is closed"); + List matches; + // Close is performed by another thread, so we need to synchronize access to the scanner + // In a single-threaded context because of the lite locking mechanism by JVM, the performance + // impact should be minimal + synchronized (scanner) { + Preconditions.checkState(!closed.get(), "Pattern filter is closed"); + matches = scanner.scan(database, input); + } + List potentialMatches = new ArrayList<>(matches.size() + notFilterable.size()); + for (Match match : matches) { + potentialMatches.add(filterable.get(match.getMatchedExpression().getId())); + } + potentialMatches.addAll(notFilterable); + return potentialMatches; + } + + @Override + public void close() throws IOException { + close(closed, scanner, database); + } + + @Override + public Runnable getCloseAction() { + AtomicBoolean closed = this.closed; + Database database = this.database; + Scanner scanner = this.scanner; + // Use local copies to avoid lambda capturing the whole instance, which could prevent GC + return () -> { + try { + close(closed, scanner, database); + } catch (IOException e) { + // Log or handle exception if needed + } + }; + } +} + diff --git a/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterProxy.java b/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterProxy.java new file mode 100644 index 0000000..ea41bcc --- /dev/null +++ b/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterProxy.java @@ -0,0 +1,23 @@ +package com.gliwka.hyperscan.util; + +import java.util.List; + +final class ScopedPatternFilterProxy implements ScopedPatternFilter { + + private final ScopedPatternFilter delegate; + + ScopedPatternFilterProxy(ScopedPatternFilter delegate) { + this.delegate = delegate; + } + + @Override + public void close() { + // No operation performed on close + } + + @Override + public List filter(String input) { + return delegate.filter(input); + } +} + diff --git a/src/test/java/com/gliwka/hyperscan/util/PatternFilterCleanerTest.java b/src/test/java/com/gliwka/hyperscan/util/PatternFilterCleanerTest.java new file mode 100644 index 0000000..0d9487a --- /dev/null +++ b/src/test/java/com/gliwka/hyperscan/util/PatternFilterCleanerTest.java @@ -0,0 +1,74 @@ +package com.gliwka.hyperscan.util; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.lang.ref.ReferenceQueue; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class PatternFilterCleanerTest { + + @Test + void clean_shouldExecuteTheCloseAction() { + // Setup + final AtomicBoolean closeActionWasRun = new AtomicBoolean(false); + Runnable thunk = () -> closeActionWasRun.set(true); + + FakeScopedPatternFilter referent = new FakeScopedPatternFilter(thunk); + ReferenceQueue> queue = new ReferenceQueue<>(); + PatternFilterCleaner cleaner = new PatternFilterCleaner(referent, queue); + + // Execute + cleaner.clean(); + + // Verify + assertThat(closeActionWasRun.get()).isTrue(); + } + + @Test + void clean_shouldSwallowExceptionsThrownByTheCloseAction() { + // Setup: A close action that always throws an exception + Runnable thunk = () -> { + throw new IllegalStateException("Test exception"); + }; + FakeScopedPatternFilter referent = new FakeScopedPatternFilter(thunk); + ReferenceQueue> queue = new ReferenceQueue<>(); + PatternFilterCleaner cleaner = new PatternFilterCleaner(referent, queue); + + // Execute & Verify + // The test passes if clean() does not throw an exception + assertDoesNotThrow(cleaner::clean); + } + + /** + * A simple, concrete implementation of ScopedPatternFilter for testing purposes. + */ + private static class FakeScopedPatternFilter implements ScopedPatternFilter { + private final Runnable closeAction; + + FakeScopedPatternFilter(Runnable closeAction) { + this.closeAction = closeAction; + } + + @Override + public List filter(String input) { + return Collections.emptyList(); + } + + + @Override + public void close() throws IOException { + // No-op for this fake + } + + @Override + public Runnable getCloseAction() { + return closeAction; + } + } +} \ No newline at end of file diff --git a/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactoryTest.java b/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactoryTest.java new file mode 100644 index 0000000..51997e3 --- /dev/null +++ b/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactoryTest.java @@ -0,0 +1,193 @@ +package com.gliwka.hyperscan.util; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.fail; + +/** + * Expert-level tests for ScopedPatternFilterFactory using JUnit 5 and Java 8. + * These tests focus on the core responsibilities: lifecycle, thread-safety, + * automatic resource reclamation, and instance isolation. + */ +class ScopedPatternFilterFactoryTest { + + // A simple, Hyperscan-compatible pattern for reliable factory initialization. + private final List testPatterns = Collections.singletonList(Pattern.compile("test")); + + // === Test Case 1: Thread-Local Caching Behavior === + @Test + void get_shouldReturnSameDelegateInstanceForSameThread() { + try (ScopedPatternFilterFactory factory = ScopedPatternFilterFactory.ofPatterns(testPatterns)) { + ScopedPatternFilter filter1 = factory.get(); + ScopedPatternFilter filter2 = factory.get(); + + assertThat(filter1).isInstanceOf(ScopedPatternFilterProxy.class); + assertThat(filter2).isInstanceOf(ScopedPatternFilterProxy.class); + + // Crucially, the underlying delegate instance must be the same. + assertThat(getDelegate(filter1)).isSameAs(getDelegate(filter2)); + assertThat(factory.getThreadLocalFilters().size()).isEqualTo(1); + } + } + + // === Test Case 2: Thread Isolation === + @Test + void get_shouldReturnDifferentDelegateInstancesForDifferentThreads() throws ExecutionException, InterruptedException { + ExecutorService executor = Executors.newFixedThreadPool(2); + try (ScopedPatternFilterFactory factory = ScopedPatternFilterFactory.ofPatterns(testPatterns)) { + Future> future1 = executor.submit(() -> getDelegate(factory.get())); + Future> future2 = executor.submit(() -> getDelegate(factory.get())); + + ScopedPatternFilter delegate1 = future1.get(); + ScopedPatternFilter delegate2 = future2.get(); + + assertThat(delegate1).isNotNull(); + assertThat(delegate2).isNotNull(); + assertThat(delegate1).isNotSameAs(delegate2); + assertThat(factory.getThreadLocalFilters().size()).isEqualTo(2); + } finally { + executor.shutdown(); + } + } + + // === Test Case 3: Explicit Close and Resource Invalidation === + @Test + void close_shouldInvalidateAllActiveFiltersCreatedByIt() { + try (ScopedPatternFilterFactory factory = ScopedPatternFilterFactory.ofPatterns(testPatterns)) { + // Dispense a filter and confirm it works. + ScopedPatternFilter activeFilter = factory.get(); + assertThat(activeFilter.filter("test")).isNotEmpty(); + + // Close the factory. + factory.close(); + + // The previously dispensed filter must now be unusable and throw an exception. + assertThatThrownBy(() -> activeFilter.filter("test")).isInstanceOf(IllegalStateException.class).hasMessage("Pattern filter is closed"); + } + } + + // === Test Case 4: State after Closing === + @Test + void close_shouldPreventNewFiltersFromBeingCreated() { + ScopedPatternFilterFactory factory = ScopedPatternFilterFactory.ofPatterns(testPatterns); + factory.close(); + + assertThatThrownBy(factory::get).isInstanceOf(IllegalStateException.class).hasMessage("ScopedPatternFilterFactory is closed."); + } + + // === Test Case 5: Automatic Resource Reclamation via GC === + @Test + void get_filterShouldBeCleanedUpAutomaticallyWhenItsThreadDies() throws InterruptedException { + try (ScopedPatternFilterFactory factory = ScopedPatternFilterFactory.ofPatterns(testPatterns)) { + // Step 1: Create a filter in a new thread, which then terminates. + Thread ephemeralThread = new Thread(factory::get); + ephemeralThread.start(); + ephemeralThread.join(); // Wait for the thread to die. + + // At this point, the factory is tracking the created filter and its cleaner. + assertThat(factory.getRefKeeper().size()).isEqualTo(1); + assertThat(factory.getThreadLocalFilters().size()).isEqualTo(1); + + // Step 2: Make the Thread object unreachable to allow it to be GC'd. + // noinspection UnusedAssignment + ephemeralThread = null; + factory.getThreadLocalFilters().clear(); + + // Step 3: Repeatedly suggest GC and wait for the factory's background cleaner task + // to process the phantom reference and remove it from the tracking set. + long timeout = System.currentTimeMillis() + 5000; // 5-second timeout + boolean wasCleaned = false; + while (System.currentTimeMillis() < timeout) { + System.gc(); + if (factory.getRefKeeper().isEmpty()) { + wasCleaned = true; + break; + } + Thread.sleep(200); + } + + if (!wasCleaned) { + fail("Automatic resource cleanup failed: refKeeper was not cleared within the timeout."); + } + } + } + + // === Test Case 6: Factory Instance Isolation === + @Test + void close_shouldHaveNoEffectOnOtherFactoryInstances() { + List patterns2 = Collections.singletonList(Pattern.compile("other")); + + try (ScopedPatternFilterFactory factory1 = ScopedPatternFilterFactory.ofPatterns(testPatterns); ScopedPatternFilterFactory factory2 = ScopedPatternFilterFactory.ofPatterns(patterns2)) { + + ScopedPatternFilter filter1 = factory1.get(); + ScopedPatternFilter filter2 = factory2.get(); + + // Close the first factory. + factory1.close(); + + // Verify the second factory and its filter remain fully operational. + assertThat(factory2.get()).isNotNull(); + assertThat(filter2.filter("other")).isNotEmpty(); + + // Verify the first factory's filter is now dead. + assertThatThrownBy(() -> filter1.filter("test")).isInstanceOf(IllegalStateException.class); + } + } + + // === Test Case 7: Proxy Behavior Verification === + @Test + void get_returnsProxyWhoseCloseMethodIsANoOp() throws IOException { + try (ScopedPatternFilterFactory factory = ScopedPatternFilterFactory.ofPatterns(testPatterns)) { + ScopedPatternFilter proxy = factory.get(); + ScopedPatternFilter delegate = getDelegate(proxy); + + // Calling close on the proxy should do nothing. + proxy.close(); + + // The underlying delegate should remain active and usable. + assertThat(delegate.filter("test")).isNotEmpty(); + } + } + + // === Test Case 8: Constructor Input Validation === + @Test + void constructor_shouldRejectInvalidInputs() { + // Null patterns iterable + assertThatThrownBy(() -> ScopedPatternFilterFactory.ofPatterns(null)).isInstanceOf(NullPointerException.class).hasMessage("patterns cannot be null"); + + // Empty patterns iterable + assertThatThrownBy(() -> ScopedPatternFilterFactory.ofPatterns(Collections.emptyList())).isInstanceOf(IllegalArgumentException.class).hasMessage("patterns cannot be empty"); + + // Null pattern mapper + assertThatThrownBy(() -> new ScopedPatternFilterFactory<>(testPatterns, null)).isInstanceOf(NullPointerException.class).hasMessage("patternMapper cannot be null"); + } + + /** + * Helper method to extract the underlying delegate from the proxy via reflection for testing. + */ + @SuppressWarnings("unchecked") + private ScopedPatternFilter getDelegate(ScopedPatternFilter proxy) { + try { + if (!(proxy instanceof ScopedPatternFilterProxy)) { + throw new IllegalArgumentException("Expected a proxy instance, but got " + proxy.getClass().getName()); + } + Field delegateField = ScopedPatternFilterProxy.class.getDeclaredField("delegate"); + delegateField.setAccessible(true); + return (ScopedPatternFilter) delegateField.get(proxy); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException("Failed to get delegate via reflection", e); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterImplTest.java b/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterImplTest.java new file mode 100644 index 0000000..d171f5d --- /dev/null +++ b/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterImplTest.java @@ -0,0 +1,81 @@ +package com.gliwka.hyperscan.util; + +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; +import java.util.function.Function; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for ScopedPatternFilterImpl. + *

+ * NOTE: These tests require at least one Hyperscan-compatible pattern to successfully + * initialize the underlying Hyperscan database. By mixing compatible and incompatible + * patterns, we can test the filtering logic without mocking. + */ +class ScopedPatternFilterImplTest { + + // A simple pattern that is compatible with Hyperscan. + private final Pattern compatiblePattern = Pattern.compile("foobar"); + // A pattern with a lookbehind, which is not supported by Hyperscan. + private final Pattern incompatiblePattern = Pattern.compile("a++"); + + private ScopedPatternFilterImpl filter; + + @BeforeEach + void setUp() throws Exception { + // Initialize with a mix of patterns. This ensures the Hyperscan database + // can be compiled with at least one valid expression. + List allPatterns = ImmutableList.of(compatiblePattern, incompatiblePattern); + filter = new ScopedPatternFilterImpl<>(allPatterns, Function.identity()); + } + + @AfterEach + void tearDown() throws IOException { + if (filter != null) { + filter.close(); + } + } + + @Test + void filter_whenMatchOccurs_shouldReturnMatchingPatternAndAllIncompatiblePatterns() { + List result = filter.filter("some text with foobar inside"); + + // Expecting the pattern that matched ("foobar") AND the pattern that couldn't be filtered. + assertThat(result).containsExactlyInAnyOrder(compatiblePattern, incompatiblePattern); + } + + @Test + void filter_whenNoMatchOccurs_shouldReturnOnlyIncompatiblePatterns() { + List result = filter.filter("some text with no matches"); + + // No compatible patterns matched, so we only get the fallback "not filterable" pattern. + // This is the corrected test for the previously failing logic. + assertThat(result).containsExactly(incompatiblePattern); + } + + @Test + void filter_shouldThrowIllegalStateExceptionIfClosed() throws IOException { + filter.close(); + + assertThatThrownBy(() -> filter.filter("some input")).isInstanceOf(IllegalStateException.class).hasMessage("Pattern filter is closed"); + } + + @Test + void getCloseAction_shouldReturnRunnableThatClosesFilter() { + Runnable closeAction = filter.getCloseAction(); + + // Run the close action + closeAction.run(); + + // After running, the filter should be closed + assertThatThrownBy(() -> filter.filter("test")).isInstanceOf(IllegalStateException.class).hasMessage("Pattern filter is closed"); + } +} \ No newline at end of file diff --git a/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterProxy.java b/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterProxy.java new file mode 100644 index 0000000..83c84c6 --- /dev/null +++ b/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterProxy.java @@ -0,0 +1,98 @@ +package com.gliwka.hyperscan.util; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScopedPatternFilterProxyTest { + + private FakeDelegateFilter delegate; + private ScopedPatternFilterProxy proxy; + + @BeforeEach + void setUp() { + delegate = new FakeDelegateFilter<>(); + proxy = new ScopedPatternFilterProxy<>(delegate); + } + + @Test + void filter_shouldDelegateCallToWrappedInstance() { + String input = "test data"; + List expectedResult = Collections.singletonList("match"); + delegate.setNextResult(expectedResult); + + List actualResult = proxy.filter(input); + + assertThat(actualResult).isSameAs(expectedResult); + assertThat(delegate.getFilterCallCount()).isEqualTo(1); + assertThat(delegate.getLastFilteredInput()).isEqualTo(input); + } + + @Test + void apply_shouldDelegateToFilterMethod() { + String input = "test data"; + List expectedResult = Collections.singletonList("match"); + delegate.setNextResult(expectedResult); + + List actualResult = proxy.apply(input); + + assertThat(actualResult).isSameAs(expectedResult); + assertThat(delegate.getFilterCallCount()).isEqualTo(1); + assertThat(delegate.getLastFilteredInput()).isEqualTo(input); + } + + @Test + void close_shouldBeANoOpAndNotCallCloseOnDelegate() { + // The proxy's purpose is to prevent users from closing the underlying + // thread-local filter instance, which is managed by the factory. + proxy.close(); + + assertThat(delegate.isClosed()).isFalse(); + } + + /** + * A fake ScopedPatternFilter that records interactions for verification. + */ + private static class FakeDelegateFilter implements ScopedPatternFilter { + private final AtomicInteger filterCallCount = new AtomicInteger(0); + private final AtomicBoolean closed = new AtomicBoolean(false); + private List nextResult = Collections.emptyList(); + private String lastFilteredInput; + + @Override + public List filter(String input) { + this.lastFilteredInput = input; + filterCallCount.incrementAndGet(); + return nextResult; + } + + @Override + public void close() throws IOException { + closed.set(true); + } + + // --- Test helper methods --- + public int getFilterCallCount() { + return filterCallCount.get(); + } + + public boolean isClosed() { + return closed.get(); + } + + public void setNextResult(List nextResult) { + this.nextResult = nextResult; + } + + public String getLastFilteredInput() { + return lastFilteredInput; + } + } +} From c26675b837a430d6e76cc4872a7e3f04cee9606b Mon Sep 17 00:00:00 2001 From: "yuyu.zhao" Date: Sun, 28 Sep 2025 16:19:19 +0800 Subject: [PATCH 2/5] fix test name --- ...dPatternFilterProxy.java => ScopedPatternFilterProxyTest.java} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/test/java/com/gliwka/hyperscan/util/{ScopedPatternFilterProxy.java => ScopedPatternFilterProxyTest.java} (100%) diff --git a/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterProxy.java b/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterProxyTest.java similarity index 100% rename from src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterProxy.java rename to src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterProxyTest.java From fd0a10e2dd71c2068a25ed2bec7fce323d5a6116 Mon Sep 17 00:00:00 2001 From: "yuyu.zhao" Date: Mon, 29 Sep 2025 17:33:27 +0800 Subject: [PATCH 3/5] use caffeine to replace guava --- pom.xml | 6 +- .../util/ScopedPatternFilterFactory.java | 65 +++++++++++++++---- .../util/ScopedPatternFilterImpl.java | 20 +++--- .../util/ScopedPatternFilterFactoryTest.java | 2 +- .../util/ScopedPatternFilterImplTest.java | 8 +-- 5 files changed, 73 insertions(+), 28 deletions(-) diff --git a/pom.xml b/pom.xml index 1201192..15aa16b 100644 --- a/pom.xml +++ b/pom.xml @@ -243,9 +243,9 @@ 1.5.11 - com.google.guava - guava - 33.5.0-jre + com.github.ben-manes.caffeine + caffeine + 3.1.8 org.junit.jupiter diff --git a/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactory.java b/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactory.java index 800aec4..608da5b 100644 --- a/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactory.java +++ b/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactory.java @@ -1,26 +1,27 @@ package com.gliwka.hyperscan.util; +import com.github.benmanes.caffeine.cache.Caffeine; import com.gliwka.hyperscan.wrapper.CompileErrorException; -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.MapMaker; -import com.google.common.util.concurrent.ThreadFactoryBuilder; import lombok.AccessLevel; import lombok.Getter; import java.io.Closeable; import java.io.IOException; import java.lang.ref.ReferenceQueue; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import java.util.function.Supplier; import java.util.regex.Pattern; @@ -86,18 +87,32 @@ public final class ScopedPatternFilterFactory implements Supplier>, Closeable { // A single, shared, daemon cleaner thread for all factory instances. - private static final ScheduledExecutorService CLEANER_SERVICE = Executors.newSingleThreadScheduledExecutor(new ThreadFactoryBuilder().setThreadFactory(Executors.defaultThreadFactory()).setNameFormat("ScopedPatternFilter-Shared-Cleaner-%d").setDaemon(true).build()); + private static final ScheduledExecutorService CLEANER_SERVICE = Executors.newSingleThreadScheduledExecutor(new NamedDaemonThreadFactory()); // --- Instance-specific fields --- private final ReferenceQueue> referenceQueue = new ReferenceQueue<>(); private final AtomicBoolean closed = new AtomicBoolean(false); + /** + * This set holds strong references to the PatternFilterCleaner objects. + * This is necessary because if the PhantomReference objects themselves were only weakly + * reachable, they could be garbage collected before they are enqueued, and the + * cleanup logic would never run. + */ @Getter(AccessLevel.PACKAGE) @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") private final Set refKeeper = ConcurrentHashMap.newKeySet(); @Getter(AccessLevel.PACKAGE) - private final ConcurrentMap> threadLocalFilters = new MapMaker().weakKeys().makeMap(); + private final ConcurrentMap> threadLocalFilters = Caffeine.newBuilder().weakKeys().>removalListener((key, value, cause) -> { + if (value != null) { + try { + value.close(); + } catch (IOException e) { + // Log this error. + } + } + }).build().asMap(); private final ScheduledFuture cleanerTaskFuture; // Handle to this instance's cleanup task. @@ -105,10 +120,16 @@ public final class ScopedPatternFilterFactory implements Supplier patternMapper; public ScopedPatternFilterFactory(Iterable patterns, Function patternMapper) { - Preconditions.checkNotNull(patternMapper, "patternMapper cannot be null"); - Preconditions.checkNotNull(patterns, "patterns cannot be null"); - this.patterns = ImmutableList.copyOf(patterns); - Preconditions.checkArgument(!this.patterns.isEmpty(), "patterns cannot be empty"); + Objects.requireNonNull(patternMapper, "patternMapper cannot be null"); + Objects.requireNonNull(patterns, "patterns cannot be null"); + this.patterns = new ArrayList<>(); + for (T pattern : patterns) { + Objects.requireNonNull(pattern, "patterns cannot contain null elements"); + this.patterns.add(pattern); + } + if (this.patterns.isEmpty()) { + throw new IllegalArgumentException("patterns cannot be empty"); + } this.patternMapper = patternMapper; // Schedule this instance's cleanup task on the shared executor. @@ -132,8 +153,14 @@ private void cleanUp() { } } + private void ensureOpen() { + if (closed.get()) { + throw new IllegalStateException("ScopedPatternFilterFactory is closed."); + } + } + private ScopedPatternFilter createFilter() { - Preconditions.checkState(!closed.get(), "ScopedPatternFilterFactory is closed."); + ensureOpen(); try { ScopedPatternFilterImpl filter = new ScopedPatternFilterImpl<>(patterns, patternMapper); // Use this instance's referenceQueue. @@ -147,7 +174,7 @@ private ScopedPatternFilter createFilter() { @Override public ScopedPatternFilter get() { - Preconditions.checkState(!closed.get(), "ScopedPatternFilterFactory is closed."); + ensureOpen(); ScopedPatternFilter filter = threadLocalFilters.computeIfAbsent(Thread.currentThread(), t -> createFilter()); return new ScopedPatternFilterProxy<>(filter); } @@ -177,5 +204,19 @@ public void close() { refKeeper.clear(); } } + + private static final class NamedDaemonThreadFactory implements ThreadFactory { + private static final String NAME_FORMAT = "ScopedPatternFilter-Shared-Cleaner-%d"; + private final ThreadFactory delegate = Executors.defaultThreadFactory(); + private final AtomicInteger counter = new AtomicInteger(0); + + @Override + public Thread newThread(Runnable r) { + Thread t = delegate.newThread(r); + t.setName(String.format(NAME_FORMAT, counter.getAndIncrement())); + t.setDaemon(true); + return t; + } + } } diff --git a/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterImpl.java b/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterImpl.java index cb8df74..e45f726 100644 --- a/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterImpl.java +++ b/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterImpl.java @@ -6,8 +6,6 @@ import com.gliwka.hyperscan.wrapper.Expression; import com.gliwka.hyperscan.wrapper.Match; import com.gliwka.hyperscan.wrapper.Scanner; -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; import java.io.IOException; import java.util.ArrayList; @@ -53,8 +51,8 @@ final class ScopedPatternFilterImpl implements ScopedPatternFilter { this.database = Database.compile(expressions); this.scanner = new Scanner(); this.scanner.allocScratch(database); - this.filterable = ImmutableList.copyOf(filterable); - this.notFilterable = ImmutableList.copyOf(notFilterable); + this.filterable = filterable; + this.notFilterable = notFilterable; } @SuppressWarnings("SynchronizationOnLocalVariableOrMethodParameter") @@ -68,16 +66,22 @@ private static void close(AtomicBoolean closed, Scanner scanner, Database databa } } + private void ensureOpen() { + if (closed.get()) { + throw new IllegalStateException("Pattern filter is closed."); + } + } + @Override public List filter(String input) { - Preconditions.checkNotNull(input); - Preconditions.checkState(!closed.get(), "Pattern filter is closed"); + Objects.requireNonNull(input, "input cannot be null"); + ensureOpen(); List matches; // Close is performed by another thread, so we need to synchronize access to the scanner - // In a single-threaded context because of the lite locking mechanism by JVM, the performance + // In a single-threaded context because of the bias locking mechanism by JVM, the performance // impact should be minimal synchronized (scanner) { - Preconditions.checkState(!closed.get(), "Pattern filter is closed"); + ensureOpen(); matches = scanner.scan(database, input); } List potentialMatches = new ArrayList<>(matches.size() + notFilterable.size()); diff --git a/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactoryTest.java b/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactoryTest.java index 51997e3..2f547ab 100644 --- a/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactoryTest.java +++ b/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactoryTest.java @@ -74,7 +74,7 @@ void close_shouldInvalidateAllActiveFiltersCreatedByIt() { factory.close(); // The previously dispensed filter must now be unusable and throw an exception. - assertThatThrownBy(() -> activeFilter.filter("test")).isInstanceOf(IllegalStateException.class).hasMessage("Pattern filter is closed"); + assertThatThrownBy(() -> activeFilter.filter("test")).isInstanceOf(IllegalStateException.class).hasMessage("Pattern filter is closed."); } } diff --git a/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterImplTest.java b/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterImplTest.java index d171f5d..7f5d104 100644 --- a/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterImplTest.java +++ b/src/test/java/com/gliwka/hyperscan/util/ScopedPatternFilterImplTest.java @@ -1,11 +1,11 @@ package com.gliwka.hyperscan.util; -import com.google.common.collect.ImmutableList; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.io.IOException; +import java.util.Arrays; import java.util.List; import java.util.function.Function; import java.util.regex.Pattern; @@ -33,7 +33,7 @@ class ScopedPatternFilterImplTest { void setUp() throws Exception { // Initialize with a mix of patterns. This ensures the Hyperscan database // can be compiled with at least one valid expression. - List allPatterns = ImmutableList.of(compatiblePattern, incompatiblePattern); + List allPatterns = Arrays.asList(compatiblePattern, incompatiblePattern); filter = new ScopedPatternFilterImpl<>(allPatterns, Function.identity()); } @@ -65,7 +65,7 @@ void filter_whenNoMatchOccurs_shouldReturnOnlyIncompatiblePatterns() { void filter_shouldThrowIllegalStateExceptionIfClosed() throws IOException { filter.close(); - assertThatThrownBy(() -> filter.filter("some input")).isInstanceOf(IllegalStateException.class).hasMessage("Pattern filter is closed"); + assertThatThrownBy(() -> filter.filter("some input")).isInstanceOf(IllegalStateException.class).hasMessage("Pattern filter is closed."); } @Test @@ -76,6 +76,6 @@ void getCloseAction_shouldReturnRunnableThatClosesFilter() { closeAction.run(); // After running, the filter should be closed - assertThatThrownBy(() -> filter.filter("test")).isInstanceOf(IllegalStateException.class).hasMessage("Pattern filter is closed"); + assertThatThrownBy(() -> filter.filter("test")).isInstanceOf(IllegalStateException.class).hasMessage("Pattern filter is closed."); } } \ No newline at end of file From 4213005d56810ad29ea2dc9d4f4b2f98a306ea67 Mon Sep 17 00:00:00 2001 From: "yuyu.zhao" Date: Wed, 1 Oct 2025 15:30:49 +0800 Subject: [PATCH 4/5] handle removal --- .../util/ScopedPatternFilterFactory.java | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactory.java b/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactory.java index 608da5b..2faab10 100644 --- a/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactory.java +++ b/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactory.java @@ -1,6 +1,7 @@ package com.gliwka.hyperscan.util; import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.RemovalCause; import com.gliwka.hyperscan.wrapper.CompileErrorException; import lombok.AccessLevel; import lombok.Getter; @@ -104,18 +105,8 @@ public final class ScopedPatternFilterFactory implements Supplier refKeeper = ConcurrentHashMap.newKeySet(); @Getter(AccessLevel.PACKAGE) - private final ConcurrentMap> threadLocalFilters = Caffeine.newBuilder().weakKeys().>removalListener((key, value, cause) -> { - if (value != null) { - try { - value.close(); - } catch (IOException e) { - // Log this error. - } - } - }).build().asMap(); - + private final ConcurrentMap> threadLocalFilters = Caffeine.newBuilder().weakKeys().removalListener(this::handleRemoval).build().asMap(); private final ScheduledFuture cleanerTaskFuture; // Handle to this instance's cleanup task. - private final List patterns; private final Function patternMapper; @@ -140,6 +131,16 @@ public static ScopedPatternFilterFactory ofPatterns(Iterable p return new ScopedPatternFilterFactory<>(patterns, Function.identity()); } + private void handleRemoval(Thread thread, ScopedPatternFilter filter, RemovalCause cause) { + if (filter != null) { + try { + filter.close(); + } catch (IOException e) { + // Log this error. + } + } + } + // This is an instance method that knows about this instance's queue and refKeeper. private void cleanUp() { try { From 7d6a2900b55ccca43c4c560cd65cfe38653e4e23 Mon Sep 17 00:00:00 2001 From: "yuyu.zhao" Date: Thu, 16 Oct 2025 15:42:31 +0800 Subject: [PATCH 5/5] lazy loading cleaner thread --- .../hyperscan/util/ScopedPatternFilterFactory.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactory.java b/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactory.java index 2faab10..4eafce4 100644 --- a/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactory.java +++ b/src/main/java/com/gliwka/hyperscan/util/ScopedPatternFilterFactory.java @@ -87,8 +87,6 @@ */ public final class ScopedPatternFilterFactory implements Supplier>, Closeable { - // A single, shared, daemon cleaner thread for all factory instances. - private static final ScheduledExecutorService CLEANER_SERVICE = Executors.newSingleThreadScheduledExecutor(new NamedDaemonThreadFactory()); // --- Instance-specific fields --- private final ReferenceQueue> referenceQueue = new ReferenceQueue<>(); @@ -124,7 +122,7 @@ public ScopedPatternFilterFactory(Iterable patterns, Function ofPatterns(Iterable patterns) { @@ -206,6 +204,12 @@ public void close() { } } + private enum ExecutorHolder { + ; + // A single, shared, daemon cleaner thread for all factory instances. + static final ScheduledExecutorService CLEANER_SERVICE = Executors.newSingleThreadScheduledExecutor(new NamedDaemonThreadFactory()); + } + private static final class NamedDaemonThreadFactory implements ThreadFactory { private static final String NAME_FORMAT = "ScopedPatternFilter-Shared-Cleaner-%d"; private final ThreadFactory delegate = Executors.defaultThreadFactory();