-
Notifications
You must be signed in to change notification settings - Fork 201
Ludicrous Mode: Lockable resources queue contention reduction and script caching #966
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
f698eb7
38ceef2
aecec79
931fc9d
65c0c3d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,10 +38,13 @@ | |
| import java.util.Map; | ||
| import java.util.Objects; | ||
| import java.util.Set; | ||
| import java.util.concurrent.ConcurrentHashMap; | ||
| import java.util.concurrent.ExecutionException; | ||
| import java.util.logging.Level; | ||
| import java.util.logging.Logger; | ||
| import jenkins.model.Jenkins; | ||
| import jenkins.util.SystemProperties; | ||
| import org.jenkins.plugins.lockableresources.util.Constants; | ||
| import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript; | ||
| import org.jenkinsci.plugins.workflow.steps.StepContext; | ||
| import org.kohsuke.accmod.Restricted; | ||
|
|
@@ -101,6 +104,63 @@ public class LockableResource extends AbstractDescribableImpl<LockableResource> | |
|
|
||
| private static final long serialVersionUID = 1L; | ||
|
|
||
| private static final long SCRIPT_CACHE_TTL_MS = | ||
| SystemProperties.getLong(Constants.SYSTEM_PROPERTY_SCRIPT_CACHE_TTL_MS, 30_000L); | ||
| private static final long LABEL_CACHE_TTL_MS = | ||
| SystemProperties.getLong(Constants.SYSTEM_PROPERTY_LABEL_CACHE_TTL_MS, 30_000L); | ||
|
|
||
| /** Per-resource cache: Groovy script text -> (result, timestamp). */ | ||
| private transient volatile ConcurrentHashMap<String, CachedResult> scriptCache; | ||
| /** Per-resource cache: label expression -> (result, timestamp). */ | ||
| private transient volatile ConcurrentHashMap<String, CachedResult> labelCache; | ||
|
|
||
|
Comment on lines
+112
to
+116
|
||
| private static final class CachedResult { | ||
| final boolean value; | ||
| final long timestamp; | ||
|
|
||
| CachedResult(boolean value) { | ||
| this.value = value; | ||
| this.timestamp = System.currentTimeMillis(); | ||
| } | ||
|
|
||
| boolean isExpired(long ttlMs) { | ||
| return (System.currentTimeMillis() - timestamp) > ttlMs; | ||
| } | ||
| } | ||
|
|
||
| private ConcurrentHashMap<String, CachedResult> getScriptCache() { | ||
| ConcurrentHashMap<String, CachedResult> c = scriptCache; | ||
| if (c == null) { | ||
| synchronized (this) { | ||
| c = scriptCache; | ||
| if (c == null) { | ||
| scriptCache = c = new ConcurrentHashMap<>(); | ||
| } | ||
| } | ||
| } | ||
| return c; | ||
| } | ||
|
|
||
| private ConcurrentHashMap<String, CachedResult> getLabelCache() { | ||
| ConcurrentHashMap<String, CachedResult> c = labelCache; | ||
| if (c == null) { | ||
| synchronized (this) { | ||
| c = labelCache; | ||
| if (c == null) { | ||
| labelCache = c = new ConcurrentHashMap<>(); | ||
| } | ||
| } | ||
| } | ||
| return c; | ||
| } | ||
|
|
||
| void invalidateCaches() { | ||
| ConcurrentHashMap<String, CachedResult> sc = scriptCache; | ||
| if (sc != null) sc.clear(); | ||
| ConcurrentHashMap<String, CachedResult> lc = labelCache; | ||
| if (lc != null) lc.clear(); | ||
| } | ||
|
|
||
| private transient boolean isNode = false; | ||
|
|
||
| /** | ||
|
|
@@ -239,6 +299,7 @@ public void setLabels(@Nullable String labels) { | |
| } | ||
| this.labelsAsList.add(label); | ||
| } | ||
| invalidateCaches(); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -288,12 +349,25 @@ public boolean isValidLabel(@Nullable String candidate) { | |
| return true; | ||
| } | ||
|
|
||
| if (LABEL_CACHE_TTL_MS > 0) { | ||
| ConcurrentHashMap<String, CachedResult> cache = getLabelCache(); | ||
| CachedResult cached = cache.get(candidate); | ||
| if (cached != null && !cached.isExpired(LABEL_CACHE_TTL_MS)) { | ||
| return cached.value; | ||
| } | ||
| boolean result = evaluateLabelExpression(candidate); | ||
| cache.put(candidate, new CachedResult(result)); | ||
| return result; | ||
| } | ||
| return evaluateLabelExpression(candidate); | ||
| } | ||
|
|
||
| private boolean evaluateLabelExpression(@NonNull String candidate) { | ||
| final Label labelExpression = Label.parseExpression(candidate); | ||
| Set<LabelAtom> atomLabels = new HashSet<>(); | ||
| for (String label : this.getLabelsAsList()) { | ||
| atomLabels.add(new LabelAtom(label)); | ||
| } | ||
|
|
||
| return labelExpression.matches(atomLabels); | ||
| } | ||
|
|
||
|
|
@@ -330,6 +404,22 @@ public void setProperties(@Nullable List<LockableResourceProperty> properties) { | |
| @Restricted(NoExternalUse.class) | ||
| public boolean scriptMatches(@NonNull SecureGroovyScript script, @CheckForNull Map<String, Object> params) | ||
| throws ExecutionException { | ||
| if (SCRIPT_CACHE_TTL_MS > 0) { | ||
| String cacheKey = script.getScript(); | ||
| ConcurrentHashMap<String, CachedResult> cache = getScriptCache(); | ||
| CachedResult cached = cache.get(cacheKey); | ||
| if (cached != null && !cached.isExpired(SCRIPT_CACHE_TTL_MS)) { | ||
| return cached.value; | ||
| } | ||
| boolean result = evaluateScript(script, params); | ||
| cache.put(cacheKey, new CachedResult(result)); | ||
| return result; | ||
|
Comment on lines
405
to
+416
|
||
| } | ||
| return evaluateScript(script, params); | ||
|
Comment on lines
405
to
+418
|
||
| } | ||
|
|
||
| private boolean evaluateScript(@NonNull SecureGroovyScript script, @CheckForNull Map<String, Object> params) | ||
| throws ExecutionException { | ||
| Binding binding = new Binding(params); | ||
| binding.setVariable("resourceName", name); | ||
| binding.setVariable("resourceDescription", description); | ||
|
|
@@ -577,6 +667,7 @@ public void reset() { | |
| this.unReserve(); | ||
| this.unqueue(); | ||
| this.setBuild(null); | ||
| invalidateCaches(); | ||
| } | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,7 +33,10 @@ | |
| import java.util.Map; | ||
| import java.util.Set; | ||
| import java.util.concurrent.ExecutionException; | ||
| import java.util.concurrent.Executors; | ||
| import java.util.concurrent.ScheduledExecutorService; | ||
| import java.util.concurrent.TimeUnit; | ||
| import java.util.concurrent.atomic.AtomicBoolean; | ||
| import java.util.logging.Level; | ||
| import java.util.logging.Logger; | ||
| import java.util.stream.Collectors; | ||
|
|
@@ -79,6 +82,14 @@ public class LockableResourcesManager extends GlobalConfiguration { | |
| private static final int enabledCausesCount = | ||
| SystemProperties.getInteger(Constants.SYSTEM_PROPERTY_PRINT_QUEUE_INFO, 2); | ||
|
|
||
| private static final boolean asyncSaveEnabled = | ||
| SystemProperties.getBoolean(Constants.SYSTEM_PROPERTY_ASYNC_SAVE, true); | ||
| private static final long saveCoalesceMs = | ||
| SystemProperties.getLong(Constants.SYSTEM_PROPERTY_SAVE_COALESCE_MS, 1000L); | ||
|
|
||
| private transient volatile AtomicBoolean savePending; | ||
| private transient volatile ScheduledExecutorService saveExecutor; | ||
|
|
||
| @DataBoundSetter | ||
| public void setAllowEmptyOrNullValues(boolean allowEmptyOrNullValues) { | ||
| this.allowEmptyOrNullValues = allowEmptyOrNullValues; | ||
|
|
@@ -494,6 +505,29 @@ public List<LockableResource> tryQueue( | |
| Map<String, Object> params, | ||
| Logger log) | ||
| throws ExecutionException { | ||
|
|
||
| final SecureGroovyScript systemGroovyScript; | ||
| try { | ||
| systemGroovyScript = requiredResources.getResourceMatchScript(); | ||
| } catch (Descriptor.FormException x) { | ||
| throw new ExecutionException(x); | ||
| } | ||
| boolean candidatesByScript = (systemGroovyScript != null); | ||
|
|
||
| // Resolve candidates outside syncResources when possible — label matching | ||
| // and Groovy script evaluation are heavyweight and should not extend the | ||
| // critical section. | ||
| List<LockableResource> candidates = null; | ||
| if (candidatesByScript || (requiredResources.label != null && !requiredResources.label.isEmpty())) { | ||
| candidates = cachedCandidates.getIfPresent(queueItemId); | ||
| if (candidates == null) { | ||
| candidates = (systemGroovyScript == null) | ||
| ? getResourcesWithLabel(requiredResources.label) | ||
| : getResourcesMatchingScript(systemGroovyScript, params); | ||
|
Comment on lines
+519
to
+528
|
||
| cachedCandidates.put(queueItemId, candidates); | ||
| } | ||
| } | ||
|
|
||
| List<LockableResource> selected = new ArrayList<>(); | ||
| synchronized (syncResources) { | ||
| if (!checkCurrentResourcesStatus(selected, queueItemProject, queueItemId, log)) { | ||
|
|
@@ -505,26 +539,10 @@ public List<LockableResource> tryQueue( | |
| return null; | ||
| } | ||
|
|
||
| final SecureGroovyScript systemGroovyScript; | ||
| try { | ||
| systemGroovyScript = requiredResources.getResourceMatchScript(); | ||
| } catch (Descriptor.FormException x) { | ||
| throw new ExecutionException(x); | ||
| } | ||
| boolean candidatesByScript = (systemGroovyScript != null); | ||
| List<LockableResource> candidates = requiredResources.required; // default candidates | ||
|
|
||
| if (candidatesByScript || (requiredResources.label != null && !requiredResources.label.isEmpty())) { | ||
|
|
||
| candidates = cachedCandidates.getIfPresent(queueItemId); | ||
| if (candidates != null) { | ||
| candidates.retainAll(this.resources); | ||
| } else { | ||
| candidates = (systemGroovyScript == null) | ||
| ? getResourcesWithLabel(requiredResources.label) | ||
| : getResourcesMatchingScript(systemGroovyScript, params); | ||
| cachedCandidates.put(queueItemId, candidates); | ||
| } | ||
| if (candidates != null) { | ||
| candidates.retainAll(this.resources); | ||
| } else { | ||
| candidates = requiredResources.required; | ||
| } | ||
|
|
||
| for (LockableResource rs : candidates) { | ||
|
|
@@ -723,6 +741,7 @@ public void unlockResources(List<LockableResource> resourcesToUnLock, Run<?, ?> | |
|
|
||
| save(); | ||
| } | ||
| scheduleQueueMaintenance(); | ||
| } | ||
|
Comment on lines
733
to
747
|
||
|
|
||
| private boolean proceedNextContext() { | ||
|
|
@@ -1007,6 +1026,7 @@ public void unreserve(List<LockableResource> resources) { | |
|
|
||
| save(); | ||
| } | ||
| scheduleQueueMaintenance(); | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
|
|
@@ -1025,6 +1045,7 @@ public void reset(List<LockableResource> resources) { | |
| } | ||
| save(); | ||
| } | ||
| scheduleQueueMaintenance(); | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
|
|
@@ -1392,6 +1413,52 @@ public static LockableResourcesManager get() { | |
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| /** | ||
| * Trigger an immediate Queue re-evaluation so items waiting for lockable | ||
| * resources are dispatched as soon as resources become available, instead of | ||
| * waiting for the next 5-second timer tick. | ||
| * <p> | ||
| * Must be called <b>outside</b> {@code synchronized(syncResources)} to avoid | ||
| * holding the plugin lock while Jenkins acquires the Queue lock. | ||
| */ | ||
| public static void scheduleQueueMaintenance() { | ||
| Jenkins j = Jenkins.getInstanceOrNull(); | ||
| if (j != null) { | ||
| j.getQueue().scheduleMaintenance(); | ||
| } | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| private AtomicBoolean getSavePending() { | ||
| AtomicBoolean sp = savePending; | ||
| if (sp == null) { | ||
| synchronized (this) { | ||
| sp = savePending; | ||
| if (sp == null) { | ||
| savePending = sp = new AtomicBoolean(false); | ||
| } | ||
| } | ||
| } | ||
| return sp; | ||
| } | ||
|
|
||
| private ScheduledExecutorService getSaveExecutor() { | ||
| ScheduledExecutorService se = saveExecutor; | ||
| if (se == null) { | ||
| synchronized (this) { | ||
| se = saveExecutor; | ||
| if (se == null) { | ||
| saveExecutor = se = Executors.newSingleThreadScheduledExecutor(r -> { | ||
| Thread t = new Thread(r, "lockable-resources-async-save"); | ||
| t.setDaemon(true); | ||
| return t; | ||
| }); | ||
| } | ||
|
Comment on lines
+1447
to
+1458
|
||
| } | ||
| } | ||
| return se; | ||
| } | ||
|
|
||
| @Override | ||
| public void save() { | ||
| if (enableSave == -1) { | ||
|
|
@@ -1401,9 +1468,20 @@ public void save() { | |
|
|
||
| if (enableSave == 0) return; // saving is disabled | ||
|
|
||
| synchronized (syncResources) { | ||
| if (BulkChange.contains(this)) return; | ||
| if (BulkChange.contains(this)) return; | ||
|
|
||
| if (asyncSaveEnabled && saveCoalesceMs > 0) { | ||
| if (getSavePending().compareAndSet(false, true)) { | ||
| getSaveExecutor().schedule(this::doSave, saveCoalesceMs, TimeUnit.MILLISECONDS); | ||
| } | ||
| } else { | ||
| doSave(); | ||
| } | ||
|
Comment on lines
+1475
to
+1481
|
||
| } | ||
|
Comment on lines
1464
to
+1482
|
||
|
|
||
| private void doSave() { | ||
| getSavePending().set(false); | ||
| synchronized (syncResources) { | ||
| try { | ||
| getConfigFile().write(this); | ||
| } catch (IOException e) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
freePostMortemResources()holdslrm.syncResourceswhile callingresource.recycle(). Sincerecycle()calls back intoLockableResourcesManager.recycle(..), which callsunlockResources(..)/unreserve(..)that now triggerscheduleQueueMaintenance(), this can end up attempting to acquire the Jenkins Queue lock while still holdingsyncResources(lock-order inversion / potential deadlock). Consider collecting the orphan resources undersyncResources, releasing the lock, then recycling them and scheduling queue maintenance once afterwards.