diff --git a/src/main/java/jenkins/branch/OrganizationFolder.java b/src/main/java/jenkins/branch/OrganizationFolder.java index 28362fea..efc39ec5 100644 --- a/src/main/java/jenkins/branch/OrganizationFolder.java +++ b/src/main/java/jenkins/branch/OrganizationFolder.java @@ -983,96 +983,104 @@ public void onSCMHeadEvent(SCMHeadEvent event) { int matchCount = 0; if (CREATED == event.getType() || UPDATED == event.getType()) { try { - for (OrganizationFolder p : Jenkins.get().getAllItems(OrganizationFolder.class)) { - if (!p.isBuildable()) { - if (LOGGER.isLoggable(Level.FINER)) { - LOGGER.log(Level.FINER, - "{0} {1} {2,date} {2,time}: Ignoring {3} because it is disabled", - new Object[]{ - globalEventDescription, - event.getType().name(), event.getTimestamp(), p.getFullName() - } - ); + List folders = Jenkins.get().getAllItems(OrganizationFolder.class); + + // Use async helper for conditional async processing + matchCount = OrganizationFolderAsyncHelper.processWithTimeout( + folders, + (p, globalListener) -> { + if (!p.isBuildable()) { + if (LOGGER.isLoggable(Level.FINER)) { + LOGGER.log(Level.FINER, + "{0} {1} {2,date} {2,time}: Ignoring {3} because it is disabled", + new Object[]{ + globalEventDescription, + event.getType().name(), event.getTimestamp(), p.getFullName() + } + ); + } + return false; } - continue; - } - // we want to catch when a branch is created / updated and consequently becomes eligible - // against the criteria. First check if the event matches one of the navigators - SCMNavigator navigator = null; - for (SCMNavigator n : p.getSCMNavigators()) { - if (event.isMatch(n)) { - matchCount++; - global.getLogger().format("Found match against %s%n", p.getFullName()); - navigator = n; - break; + // we want to catch when a branch is created / updated and consequently becomes eligible + // against the criteria. First check if the event matches one of the navigators + SCMNavigator navigator = null; + for (SCMNavigator n : p.getSCMNavigators()) { + if (event.isMatch(n)) { + globalListener.getLogger().format("Found match against %s%n", p.getFullName()); + navigator = n; + break; + } } - } - if (navigator == null) { - continue; - } - // ok, now check if any of the sources are a match... if they are then this event is not our - // concern - for (SCMSource s : p.getSCMSources()) { - if (event.isMatch(s)) { - // already have a source that will see this - global.getLogger() - .format("Project %s already has a corresponding sub-project%n", - p.getFullName()); - navigator = null; - break; + if (navigator == null) { + return false; } - } - if (navigator != null) { - global.getLogger() - .format("Project %s does not have a corresponding sub-project%n", - p.getFullName()); - String localEventDescription = StringUtils.defaultIfBlank( - event.descriptionFor(navigator), - globalEventDescription - ); - try (StreamTaskListener listener = p.getComputation().createEventsListener(); - ChildObserver childObserver = p.openEventsChildObserver()) { - long start = System.currentTimeMillis(); - listener.getLogger() - .format("[%tc] Received %s %s event from %s with timestamp %tc%n", - start, localEventDescription, event.getType().name(), - event.getOrigin(), - event.getTimestamp()); - try { - navigator.visitSources( - p.new SCMSourceObserverImpl(listener, childObserver, navigator, event), - event); + // ok, now check if any of the sources are a match... if they are then this event is not our + // concern + for (SCMSource s : p.getSCMSources()) { + if (event.isMatch(s)) { + // already have a source that will see this + globalListener.getLogger() + .format("Project %s already has a corresponding sub-project%n", + p.getFullName()); + navigator = null; + break; + } + } + if (navigator != null) { + globalListener.getLogger() + .format("Project %s does not have a corresponding sub-project%n", + p.getFullName()); + String localEventDescription = StringUtils.defaultIfBlank( + event.descriptionFor(navigator), + globalEventDescription + ); + try (StreamTaskListener listener = p.getComputation().createEventsListener(); + ChildObserver childObserver = p.openEventsChildObserver()) { + long start = System.currentTimeMillis(); + listener.getLogger() + .format("[%tc] Received %s %s event from %s with timestamp %tc%n", + start, localEventDescription, event.getType().name(), + event.getOrigin(), + event.getTimestamp()); + try { + navigator.visitSources( + p.new SCMSourceObserverImpl(listener, childObserver, navigator, event), + event); + } catch (IOException e) { + printStackTrace(e, listener.error(e.getMessage())); + } catch (InterruptedException e) { + listener.error(e.getMessage()); + throw e; + } finally { + long end = System.currentTimeMillis(); + listener.getLogger().format( + "[%tc] %s %s event from %s with timestamp %tc processed in %s%n", + end, localEventDescription, event.getType().name(), + event.getOrigin(), event.getTimestamp(), + Util.getTimeSpanString(end - start)); + } } catch (IOException e) { - printStackTrace(e, listener.error(e.getMessage())); + printStackTrace(e, globalListener.error( + "[%tc] %s encountered an error while processing %s %s event from %s with " + + "timestamp %tc", + System.currentTimeMillis(), ModelHyperlinkNote.encodeTo(p), + globalEventDescription, event.getType().name(), + event.getOrigin(), event.getTimestamp())); } catch (InterruptedException e) { - listener.error(e.getMessage()); + globalListener.error( + "[%tc] %s was interrupted while processing %s %s event from %s with " + + "timestamp %tc", + System.currentTimeMillis(), ModelHyperlinkNote.encodeTo(p), + globalEventDescription, event.getType().name(), + event.getOrigin(), event.getTimestamp()); throw e; - } finally { - long end = System.currentTimeMillis(); - listener.getLogger().format( - "[%tc] %s %s event from %s with timestamp %tc processed in %s%n", - end, localEventDescription, event.getType().name(), - event.getOrigin(), event.getTimestamp(), - Util.getTimeSpanString(end - start)); } - } catch (IOException e) { - printStackTrace(e, global.error( - "[%tc] %s encountered an error while processing %s %s event from %s with " - + "timestamp %tc", - System.currentTimeMillis(), ModelHyperlinkNote.encodeTo(p), - globalEventDescription, event.getType().name(), - event.getOrigin(), event.getTimestamp())); - } catch (InterruptedException e) { - global.error( - "[%tc] %s was interrupted while processing %s %s event from %s with " - + "timestamp %tc", - System.currentTimeMillis(), ModelHyperlinkNote.encodeTo(p), - globalEventDescription, event.getType().name(), - event.getOrigin(), event.getTimestamp()); - throw e; + return true; // Found and processed a match } - } - } + return false; // No navigator match + }, + global + ); } catch (InterruptedException e) { printStackTrace(e, global.error( "[%tc] Interrupted while processing %s %s event from %s with timestamp %tc", @@ -1103,18 +1111,21 @@ public void onSCMNavigatorEvent(SCMNavigatorEvent event) { event.getOrigin(), event.getTimestamp()); int matchCount = 0; if (UPDATED == event.getType()) { - Set matches = new HashSet<>(); try { - for (OrganizationFolder p : Jenkins.get().getAllItems(OrganizationFolder.class)) { - matches.clear(); - for (SCMNavigator n : p.getSCMNavigators()) { - if (event.isMatch(n)) { - matches.add(n); + List folders = Jenkins.get().getAllItems(OrganizationFolder.class); + + // Use async helper for conditional async processing + matchCount = OrganizationFolderAsyncHelper.processWithTimeout( + folders, + (p, globalListener) -> { + Set matches = new HashSet<>(); + for (SCMNavigator n : p.getSCMNavigators()) { + if (event.isMatch(n)) { + matches.add(n); + } } - } - if (!matches.isEmpty()) { - matchCount++; - try (StreamTaskListener listener = p.getComputation().createEventsListener()) { + if (!matches.isEmpty()) { + try (StreamTaskListener listener = p.getComputation().createEventsListener()) { Map> navigatorActions = new HashMap<>(); for (SCMNavigator navigator : matches) { try { @@ -1157,25 +1168,29 @@ public void onSCMNavigatorEvent(SCMNavigatorEvent event) { bc.abort(); } } - } catch (IOException e) { - printStackTrace(e, global.error( - "[%tc] %s encountered an error while processing %s %s event from %s with " - + "timestamp %tc", - - System.currentTimeMillis(), ModelHyperlinkNote.encodeTo(p), - event.getClass().getName(), event.getType().name(), - event.getOrigin(), event.getTimestamp())); - } catch (InterruptedException e) { - global.error( - "[%tc] %s was interrupted while processing %s %s event from %s with " - + "timestamp %tc", - System.currentTimeMillis(), ModelHyperlinkNote.encodeTo(p), - event.getClass().getName(), event.getType().name(), - event.getOrigin(), event.getTimestamp()); - throw e; + } catch (IOException e) { + printStackTrace(e, globalListener.error( + "[%tc] %s encountered an error while processing %s %s event from %s with " + + "timestamp %tc", + + System.currentTimeMillis(), ModelHyperlinkNote.encodeTo(p), + event.getClass().getName(), event.getType().name(), + event.getOrigin(), event.getTimestamp())); + } catch (InterruptedException e) { + globalListener.error( + "[%tc] %s was interrupted while processing %s %s event from %s with " + + "timestamp %tc", + System.currentTimeMillis(), ModelHyperlinkNote.encodeTo(p), + event.getClass().getName(), event.getType().name(), + event.getOrigin(), event.getTimestamp()); + throw e; + } + return true; // Found and processed matches } - } - } + return false; // No matches + }, + global + ); } catch (InterruptedException e) { printStackTrace(e, global.error( "[%tc] Interrupted while processing %s %s event from %s with timestamp %tc", @@ -1206,17 +1221,21 @@ public void onSCMSourceEvent(SCMSourceEvent event) { int matchCount = 0; if (CREATED == event.getType()) { try { - for (OrganizationFolder p : Jenkins.get().getAllItems(OrganizationFolder.class)) { - boolean haveMatch = false; - for (SCMNavigator n : p.getSCMNavigators()) { - if (event.isMatch(n)) { - global.getLogger().format("Found match against %s%n", p.getFullName()); - haveMatch = true; - break; + List folders = Jenkins.get().getAllItems(OrganizationFolder.class); + + // Use async helper for conditional async processing + matchCount = OrganizationFolderAsyncHelper.processWithTimeout( + folders, + (p, globalListener) -> { + boolean haveMatch = false; + for (SCMNavigator n : p.getSCMNavigators()) { + if (event.isMatch(n)) { + globalListener.getLogger().format("Found match against %s%n", p.getFullName()); + haveMatch = true; + break; + } } - } - if (haveMatch) { - matchCount++; + if (haveMatch) { try (StreamTaskListener listener = p.getComputation().createEventsListener(); ChildObserver childObserver = p.openEventsChildObserver()) { long start = System.currentTimeMillis(); @@ -1249,25 +1268,29 @@ p.new SCMSourceObserverImpl(listener, childObserver, n, event.getOrigin(), event.getTimestamp(), Util.getTimeSpanString(end - start)); } - } catch (IOException e) { - printStackTrace(e, global.error( - "[%tc] %s encountered an error while processing %s %s event from %s with " - + "timestamp %tc", - - System.currentTimeMillis(), ModelHyperlinkNote.encodeTo(p), - event.getClass().getName(), event.getType().name(), - event.getOrigin(), event.getTimestamp())); - } catch (InterruptedException e) { - global.error( - "[%tc] %s was interrupted while processing %s %s event from %s with " - + "timestamp %tc", - System.currentTimeMillis(), ModelHyperlinkNote.encodeTo(p), - event.getClass().getName(), event.getType().name(), - event.getOrigin(), event.getTimestamp()); - throw e; + } catch (IOException e) { + printStackTrace(e, globalListener.error( + "[%tc] %s encountered an error while processing %s %s event from %s with " + + "timestamp %tc", + + System.currentTimeMillis(), ModelHyperlinkNote.encodeTo(p), + event.getClass().getName(), event.getType().name(), + event.getOrigin(), event.getTimestamp())); + } catch (InterruptedException e) { + globalListener.error( + "[%tc] %s was interrupted while processing %s %s event from %s with " + + "timestamp %tc", + System.currentTimeMillis(), ModelHyperlinkNote.encodeTo(p), + event.getClass().getName(), event.getType().name(), + event.getOrigin(), event.getTimestamp()); + throw e; + } + return true; // Found and processed a match } - } - } + return false; // No match + }, + global + ); } catch (InterruptedException e) { printStackTrace(e, global.error( "[%tc] Interrupted while processing %s %s event from %s with timestamp %tc", diff --git a/src/main/java/jenkins/branch/OrganizationFolderAsyncHelper.java b/src/main/java/jenkins/branch/OrganizationFolderAsyncHelper.java new file mode 100644 index 00000000..7bca0e6d --- /dev/null +++ b/src/main/java/jenkins/branch/OrganizationFolderAsyncHelper.java @@ -0,0 +1,155 @@ +/* + * The MIT License + * + * Copyright 2024 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.branch; + +import hudson.model.Computer; +import hudson.util.StreamTaskListener; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Helper class to handle asynchronous processing of OrganizationFolder webhook events. + * + * This class is used to prevent one slow/blocked OrganizationFolder from blocking + * webhook processing for other OrganizationFolders. + * + * @since 2.999999 + */ +class OrganizationFolderAsyncHelper { + + private static final Logger LOGGER = Logger.getLogger(OrganizationFolderAsyncHelper.class.getName()); + + /** + * Timeout for individual folder processing in minutes. + */ + private static final long FOLDER_TIMEOUT_MINUTES = 5; + + /** + * Threshold for using async processing. If there are more than this many + * OrganizationFolders, we'll use async processing to prevent blocking. + */ + private static final int ASYNC_THRESHOLD = 1; + + /** + * Process a folder operation, potentially asynchronously if there are multiple folders. + * + * @param folders The list of all OrganizationFolders to process + * @param operation The operation to perform on each folder + * @param global The global event listener for logging + * @return The total number of matches found across all folders + */ + static int processWithTimeout( + List folders, + FolderOperation operation, + StreamTaskListener global) throws InterruptedException { + + int matchCount = 0; + + // If there's only one folder or we're below the threshold, process synchronously + // This maintains backward compatibility and keeps tests happy + if (folders.size() <= ASYNC_THRESHOLD) { + for (OrganizationFolder folder : folders) { + try { + if (operation.process(folder, global)) { + matchCount++; + } + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Error processing folder " + folder.getFullName(), e); + } + } + return matchCount; + } + + // Multiple folders - use async processing to prevent blocking + List> futures = new ArrayList<>(); + + for (OrganizationFolder folder : folders) { + Callable task = () -> { + try { + return operation.process(folder, global); + } catch (InterruptedException e) { + global.error("[%tc] %s was interrupted while processing event", + System.currentTimeMillis(), folder.getFullName()); + Thread.currentThread().interrupt(); + return false; + } catch (IOException e) { + global.error("[%tc] %s encountered an error while processing event: %s", + System.currentTimeMillis(), folder.getFullName(), e.getMessage()); + return false; + } + }; + + Future future = Computer.threadPoolForRemoting.submit(task); + futures.add(future); + } + + // Wait for all futures to complete with individual timeouts + for (Future future : futures) { + try { + if (future.get(FOLDER_TIMEOUT_MINUTES, TimeUnit.MINUTES)) { + matchCount++; + } + } catch (TimeoutException e) { + global.error("[%tc] Timeout while waiting for folder processing to complete", + System.currentTimeMillis()); + future.cancel(true); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + LOGGER.log(Level.WARNING, "Error processing folder", cause); + } + } + + return matchCount; + } + + /** + * Interface for folder processing operations. + */ + @FunctionalInterface + interface FolderOperation { + /** + * Process a single OrganizationFolder. + * + * @param folder The folder to process + * @param global The global event listener + * @return true if the folder matched the event, false otherwise + * @throws IOException if an I/O error occurs + * @throws InterruptedException if the operation is interrupted + */ + boolean process(OrganizationFolder folder, StreamTaskListener global) + throws IOException, InterruptedException; + } +} \ No newline at end of file