Skip to content

Commit 5468e4c

Browse files
marevolclaude
andauthored
refactor(script): add compiled script caching to GroovyEngine and improve DocBoostMatcher error handling (#3074)
* refactor(script): add compiled script caching to GroovyEngine and improve DocBoostMatcher error handling GroovyEngine now caches compiled Groovy Script classes in an LRU map (max 100 entries), avoiding repeated compilation of the same script text. Each evaluation still creates a fresh Script instance for thread-safe binding isolation. GroovyClassLoaders are properly closed on eviction and via @PreDestroy. DocBoostMatcher now catches NumberFormatException when parsing boost values, logging a warning and returning 0.0f instead of propagating the exception. Includes comprehensive tests for cache behavior, binding isolation, eviction, concurrent evaluation, close lifecycle, and new DocBoostMatcher edge cases. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(script): replace synchronized LinkedHashMap with Guava Cache in GroovyEngine Replace the manual synchronized LinkedHashMap-based LRU cache with Guava Cache for compiled Groovy scripts. This provides segment-based locking for lock-free concurrent reads and automatic eviction via RemovalListener. Also makes scriptCacheSize and maxScriptLogLength configurable via DI. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 9f509fc commit 5468e4c

File tree

5 files changed

+546
-34
lines changed

5 files changed

+546
-34
lines changed

src/main/java/org/codelibs/fess/indexer/DocBoostMatcher.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
import java.util.Map;
1919

20+
import org.apache.logging.log4j.LogManager;
21+
import org.apache.logging.log4j.Logger;
2022
import org.codelibs.fess.Constants;
2123
import org.codelibs.fess.opensearch.config.exentity.BoostDocumentRule;
2224
import org.codelibs.fess.util.ComponentUtil;
@@ -29,6 +31,7 @@
2931
*
3032
*/
3133
public class DocBoostMatcher {
34+
private static final Logger logger = LogManager.getLogger(DocBoostMatcher.class);
3235

3336
/** The expression used to calculate the boost value (defaults to "0") */
3437
private String boostExpression = "0";
@@ -105,7 +108,12 @@ public float getValue(final Map<String, Object> map) {
105108
return ((Double) value).floatValue();
106109
}
107110
if (value != null) {
108-
return Float.parseFloat(value.toString());
111+
try {
112+
return Float.parseFloat(value.toString());
113+
} catch (final NumberFormatException e) {
114+
logger.warn("Failed to parse boost value: expression={}, value={}", boostExpression, value, e);
115+
return 0.0f;
116+
}
109117
}
110118

111119
return 0.0f;

src/main/java/org/codelibs/fess/script/groovy/GroovyEngine.java

Lines changed: 138 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
*/
1616
package org.codelibs.fess.script.groovy;
1717

18+
import java.io.IOException;
1819
import java.util.Collections;
1920
import java.util.HashMap;
2021
import java.util.Map;
22+
import java.util.concurrent.ExecutionException;
2123

2224
import org.apache.logging.log4j.LogManager;
2325
import org.apache.logging.log4j.Logger;
@@ -30,37 +32,100 @@
3032
import org.lastaflute.di.core.factory.SingletonLaContainerFactory;
3133
import org.lastaflute.job.LaJobRuntime;
3234

35+
import com.google.common.cache.Cache;
36+
import com.google.common.cache.CacheBuilder;
37+
import com.google.common.cache.RemovalNotification;
38+
3339
import groovy.lang.Binding;
3440
import groovy.lang.GroovyClassLoader;
35-
import groovy.lang.GroovyShell;
41+
import groovy.lang.Script;
42+
import jakarta.annotation.PostConstruct;
43+
import jakarta.annotation.PreDestroy;
3644

3745
/**
3846
* Groovy script engine implementation that extends AbstractScriptEngine.
3947
* This class provides support for executing Groovy scripts with parameter binding
4048
* and DI container integration.
4149
*
42-
* <p>Thread Safety: This class is thread-safe. Each evaluate() call creates
43-
* a new GroovyShell instance to ensure thread isolation.</p>
50+
* <p>Thread Safety: This class is thread-safe. Each cached entry holds its own
51+
* GroovyClassLoader. The cache uses Guava Cache with segment-based locking for
52+
* lock-free concurrent reads. Each evaluate() call creates a new Script instance
53+
* to ensure thread isolation of bindings.</p>
54+
*
55+
* <p>Note on class-level isolation: Compiled Script classes are cached and reused.
56+
* Class-level state (static fields, metaclass mutations) persists across evaluations
57+
* of the same script. In Fess, scripts are short expressions configured by
58+
* administrators (e.g., "data1 &gt; 10", "10 * boost1 + boost2") and do not use
59+
* static state, so this is acceptable.</p>
4460
*
45-
* <p>Resource Management: GroovyClassLoader instances are properly managed
46-
* and cleaned up after script evaluation to prevent memory leaks.</p>
61+
* <p>Resource Management: Each cached entry's GroovyClassLoader is closed on
62+
* eviction via RemovalListener. All remaining entries are cleaned up via close() (@PreDestroy).</p>
4763
*/
4864
public class GroovyEngine extends AbstractScriptEngine {
4965
private static final Logger logger = LogManager.getLogger(GroovyEngine.class);
5066

67+
/** Maximum number of compiled scripts to cache. Configurable via DI. */
68+
protected int scriptCacheSize = 1000;
69+
70+
/** Maximum length of script text included in warning log messages. Configurable via DI. */
71+
protected int maxScriptLogLength = 200;
72+
73+
private Cache<String, CachedScript> scriptCache;
74+
5175
/**
5276
* Default constructor for GroovyEngine.
5377
*/
5478
public GroovyEngine() {
5579
super();
80+
buildScriptCache();
81+
}
82+
83+
/**
84+
* Rebuilds the script cache after DI injection.
85+
* Called by the DI container after property injection.
86+
*/
87+
@PostConstruct
88+
public void init() {
89+
buildScriptCache();
90+
}
91+
92+
private void buildScriptCache() {
93+
final Cache<String, CachedScript> oldCache = scriptCache;
94+
scriptCache = CacheBuilder.newBuilder()
95+
.maximumSize(scriptCacheSize)
96+
.removalListener((final RemovalNotification<String, CachedScript> notification) -> {
97+
notification.getValue().close();
98+
})
99+
.build();
100+
if (oldCache != null) {
101+
oldCache.invalidateAll();
102+
}
103+
}
104+
105+
/**
106+
* Sets the maximum number of compiled scripts to cache.
107+
*
108+
* @param scriptCacheSize the cache size
109+
*/
110+
public void setScriptCacheSize(final int scriptCacheSize) {
111+
this.scriptCacheSize = scriptCacheSize;
112+
}
113+
114+
/**
115+
* Sets the maximum length of script text included in warning log messages.
116+
*
117+
* @param maxScriptLogLength the max length
118+
*/
119+
public void setMaxScriptLogLength(final int maxScriptLogLength) {
120+
this.maxScriptLogLength = maxScriptLogLength;
56121
}
57122

58123
/**
59124
* Evaluates a Groovy script template with the provided parameters.
60125
*
61-
* <p>This method creates a new GroovyShell instance for each evaluation to ensure
62-
* thread safety. The DI container is automatically injected into the binding map
63-
* as "container" for script access.</p>
126+
* <p>This method caches compiled Script classes per script text.
127+
* Each evaluation creates a new Script instance to ensure thread-safe binding isolation.
128+
* The DI container is automatically injected into the binding map as "container".</p>
64129
*
65130
* @param template the Groovy script to evaluate (null-safe, returns null if empty)
66131
* @param paramMap the parameters to bind to the script (null-safe, treated as empty map if null)
@@ -70,66 +135,83 @@ public GroovyEngine() {
70135
*/
71136
@Override
72137
public Object evaluate(final String template, final Map<String, Object> paramMap) {
73-
// Null-safety: return null for blank templates
74-
// Early return is safe here as no resources have been allocated yet
75138
if (StringUtil.isBlank(template)) {
76139
if (logger.isDebugEnabled()) {
77140
logger.debug("Template is blank, returning null");
78141
}
79142
return null;
80143
}
81144

82-
// Null-safety: use empty map if paramMap is null
83145
final Map<String, Object> safeParamMap = paramMap != null ? paramMap : Collections.emptyMap();
84146

85-
// Prepare binding map with parameters and DI container
86147
final Map<String, Object> bindingMap = new HashMap<>(safeParamMap);
87148
bindingMap.put("container", SingletonLaContainerFactory.getContainer());
88149

89-
// Create GroovyShell with custom class loader for proper resource management
90-
GroovyClassLoader classLoader = null;
91150
try {
92-
// Get parent class loader with fallback to ensure robustness across threading contexts
93-
ClassLoader parentClassLoader = Thread.currentThread().getContextClassLoader();
94-
if (parentClassLoader == null) {
95-
parentClassLoader = GroovyEngine.class.getClassLoader();
96-
}
97-
classLoader = new GroovyClassLoader(parentClassLoader);
98-
final GroovyShell groovyShell = new GroovyShell(classLoader, new Binding(bindingMap));
151+
final CachedScript cached = getOrCompile(template);
152+
final Script script = cached.scriptClass.getDeclaredConstructor().newInstance();
153+
script.setBinding(new Binding(bindingMap));
99154

100155
if (logger.isDebugEnabled()) {
101156
logger.debug("Evaluating Groovy script: template={}", template);
102157
}
103158

104-
final Object result = groovyShell.evaluate(template);
159+
final Object result = script.run();
105160
logScriptExecution(template, "success");
106161
return result;
107162
} catch (final JobProcessingException e) {
108-
// Rethrow JobProcessingException to allow scripts to signal job-specific errors
109-
// that should be handled by the job framework
110163
if (logger.isDebugEnabled()) {
111164
logger.debug("Script raised JobProcessingException", e);
112165
}
113166
logScriptExecution(template, "failure:" + e.getClass().getSimpleName());
114167
throw e;
115168
} catch (final Exception e) {
116-
// Log and return null for other exceptions to maintain backward compatibility
117-
logger.warn("Failed to evaluate Groovy script: template={}, parameters={}", template, safeParamMap, e);
169+
final String truncatedScript =
170+
template.length() > maxScriptLogLength ? template.substring(0, maxScriptLogLength) + "..." : template;
171+
logger.warn("Failed to evaluate Groovy script: script(length={})={}, parameterKeys={}", template.length(), truncatedScript,
172+
safeParamMap.keySet(), e);
118173
logScriptExecution(template, "failure:" + e.getClass().getSimpleName());
119174
return null;
120-
} finally {
121-
// Properly clean up GroovyClassLoader resources
122-
if (classLoader != null) {
175+
}
176+
}
177+
178+
@SuppressWarnings("unchecked")
179+
private CachedScript getOrCompile(final String template) {
180+
try {
181+
return scriptCache.get(template, () -> {
182+
ClassLoader parentClassLoader = Thread.currentThread().getContextClassLoader();
183+
if (parentClassLoader == null) {
184+
parentClassLoader = GroovyEngine.class.getClassLoader();
185+
}
186+
final GroovyClassLoader classLoader = new GroovyClassLoader(parentClassLoader);
123187
try {
124-
classLoader.clearCache();
125-
classLoader.close();
188+
final Class<? extends Script> scriptClass = (Class<? extends Script>) classLoader.parseClass(template);
189+
return new CachedScript(scriptClass, classLoader);
126190
} catch (final Exception e) {
127-
logger.warn("Failed to close GroovyClassLoader", e);
191+
try {
192+
classLoader.clearCache();
193+
classLoader.close();
194+
} catch (final IOException closeEx) {
195+
logger.warn("Failed to close GroovyClassLoader after compilation failure", closeEx);
196+
}
197+
throw e;
128198
}
129-
}
199+
});
200+
} catch (final ExecutionException e) {
201+
throw (RuntimeException) e.getCause();
130202
}
131203
}
132204

205+
/**
206+
* Closes all cached GroovyClassLoaders and clears the script cache.
207+
* Called by the DI container on shutdown.
208+
*/
209+
@PreDestroy
210+
public void close() {
211+
scriptCache.invalidateAll();
212+
scriptCache.cleanUp();
213+
}
214+
133215
/**
134216
* Returns the name identifier for this script engine.
135217
*
@@ -195,4 +277,27 @@ protected void logScriptExecution(final String script, final String result) {
195277
}
196278
}
197279

280+
/**
281+
* Holds a compiled Script class and its associated GroovyClassLoader.
282+
* When evicted from the cache, close() releases the class loader resources.
283+
*/
284+
private static class CachedScript {
285+
final Class<? extends Script> scriptClass;
286+
private final GroovyClassLoader classLoader;
287+
288+
CachedScript(final Class<? extends Script> scriptClass, final GroovyClassLoader classLoader) {
289+
this.scriptClass = scriptClass;
290+
this.classLoader = classLoader;
291+
}
292+
293+
void close() {
294+
try {
295+
classLoader.clearCache();
296+
classLoader.close();
297+
} catch (final IOException e) {
298+
LogManager.getLogger(GroovyEngine.class).warn("Failed to close GroovyClassLoader", e);
299+
}
300+
}
301+
}
302+
198303
}

src/main/resources/fess_se++.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
<components>
55
<component name="groovyEngine"
66
class="org.codelibs.fess.script.groovy.GroovyEngine">
7+
<!--
8+
<property name="scriptCacheSize">1000</property>
9+
<property name="maxScriptLogLength">200</property>
10+
-->
11+
<postConstruct name="init"></postConstruct>
712
<postConstruct name="register"></postConstruct>
813
</component>
914
</components>

0 commit comments

Comments
 (0)