1515 */
1616package org .codelibs .fess .script .groovy ;
1717
18+ import java .io .IOException ;
1819import java .util .Collections ;
1920import java .util .HashMap ;
2021import java .util .Map ;
22+ import java .util .concurrent .ExecutionException ;
2123
2224import org .apache .logging .log4j .LogManager ;
2325import org .apache .logging .log4j .Logger ;
3032import org .lastaflute .di .core .factory .SingletonLaContainerFactory ;
3133import 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+
3339import groovy .lang .Binding ;
3440import 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 > 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 */
4864public 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}
0 commit comments