From 8de14d7d84353986b89c1cb6a1983b2dfb391fe9 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Wed, 20 May 2026 10:05:07 +0200 Subject: [PATCH 01/87] Initial commit for the merge of hazelcast branches --- pom.xml | 6 + .../coordination/CoordinationModeFactory.java | 32 +- .../LocalCoordinationProvider.java | 14 + .../hazelcast/BuildCancelledProcessor.java | 85 +++ .../hazelcast/BuildCompletedProcessor.java | 95 +++ .../hazelcast/BuildMemoryKey.java | 92 +++ .../hazelcast/BuildStartedProcessor.java | 90 +++ .../coordination/hazelcast/EntryData.java | 241 ++++++++ .../hazelcast/EntryDataSerializer.java | 86 +++ .../hazelcast/EventIdentifier.java | 215 +++++++ .../HazelcastBuildMemoryStorage.java | 565 ++++++++++++++++++ .../hazelcast/HazelcastConfig.java | 314 ++++++++++ .../hazelcast/HazelcastInstanceProvider.java | 159 +++++ .../hazelcast/HazelcastManager.java | 204 +++++++ .../hazelcast/MemoryImprintData.java | 108 ++++ .../MemoryImprintDataSerializer.java | 92 +++ .../hazelcast/SetCustomUrlProcessor.java | 72 +++ .../SetUnsuccessfulMessageProcessor.java | 72 +++ .../LocalEventClaimStrategy.java | 119 ++++ .../trigger/spi/CoordinationModeProvider.java | 20 + .../trigger/spi/EventClaimStrategy.java | 136 +++++ 21 files changed, 2816 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCompletedProcessor.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildMemoryKey.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildStartedProcessor.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastInstanceProvider.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintData.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintDataSerializer.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCustomUrlProcessor.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetUnsuccessfulMessageProcessor.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalEventClaimStrategy.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java diff --git a/pom.xml b/pom.xml index b06898433..0ad330356 100644 --- a/pom.xml +++ b/pom.xml @@ -67,6 +67,7 @@ 3 0.5C High + 5.3.8 @@ -107,6 +108,11 @@ + + com.hazelcast + hazelcast + ${hazelcast.version} + io.jenkins.plugins gson-api diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/CoordinationModeFactory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/CoordinationModeFactory.java index 56405df78..37741bac2 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/CoordinationModeFactory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/CoordinationModeFactory.java @@ -25,8 +25,10 @@ import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.BuildMemoryStorage; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.EventClaimStrategy; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.NotificationClaimStrategy; import com.sonyericsson.hudson.plugins.gerrit.trigger.storage.LocalBuildMemoryStorage; +import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.LocalEventClaimStrategy; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.LocalNotificationClaimStrategy; import hudson.Extension; import hudson.ExtensionList; @@ -83,6 +85,7 @@ * @see CoordinationModeProvider * @see BuildMemoryStorage * @see NotificationClaimStrategy + * @see EventClaimStrategy */ @Extension public class CoordinationModeFactory { @@ -107,6 +110,12 @@ public class CoordinationModeFactory { */ private volatile NotificationClaimStrategy claimStrategy; + /** + * The event claim strategy instance, lazily initialized. + * Instance field - managed by Jenkins lifecycle, not static. + */ + private volatile EventClaimStrategy eventClaimStrategy; + /** * Constructor - called by Jenkins once per Jenkins instance. * Public constructor allows Jenkins to instantiate via @Extension mechanism. @@ -182,6 +191,24 @@ public NotificationClaimStrategy getClaimStrategy() { return claimStrategy; } + /** + * Gets the EventClaimStrategy instance for the current coordination mode. + * + *

Uses double-checked locking for thread-safe lazy initialization. + * The mode is discovered and instances are created on first access.

+ * + *

The EventClaimStrategy prevents duplicate build processing when multiple Jenkins + * instances receive the same Gerrit event in HA/HS deployments.

+ * + * @return the event claim strategy implementation + * @throws IllegalStateException if no available mode provider is found + */ + @NonNull + public EventClaimStrategy getEventClaimStrategy() { + ensureInitialized(); + return eventClaimStrategy; + } + /** * Ensures the factory is initialized by discovering the mode if needed. * Uses double-checked locking for thread safety. @@ -246,12 +273,14 @@ private void discoverMode() { selectedMode = selectedProvider; - // Create both implementations from the selected mode + // Create all three implementations from the selected mode storage = selectedProvider.createStorage(); claimStrategy = selectedProvider.createClaimStrategy(); + eventClaimStrategy = selectedProvider.createEventClaimStrategy(); logger.info("Created BuildMemoryStorage: {}", storage.getClass().getSimpleName()); logger.info("Created NotificationClaimStrategy: {}", claimStrategy.getClass().getSimpleName()); + logger.info("Created EventClaimStrategy: {}", eventClaimStrategy.getClass().getSimpleName()); } catch (Exception e) { logger.warn("Failed to discover mode via ExtensionList, using fallback", e); @@ -267,6 +296,7 @@ private void createFallbackMode() { logger.info("Using fallback local mode (ExtensionList unavailable)"); storage = new LocalBuildMemoryStorage(); claimStrategy = new LocalNotificationClaimStrategy(); + eventClaimStrategy = new LocalEventClaimStrategy(); selectedMode = null; // No provider in fallback mode } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java index 9411b09fb..11a8ef0e8 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java @@ -23,9 +23,11 @@ */ package com.sonyericsson.hudson.plugins.gerrit.trigger.coordination; +import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.LocalEventClaimStrategy; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.LocalNotificationClaimStrategy; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.BuildMemoryStorage; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.EventClaimStrategy; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.NotificationClaimStrategy; import com.sonyericsson.hudson.plugins.gerrit.trigger.storage.LocalBuildMemoryStorage; import hudson.Extension; @@ -45,6 +47,7 @@ * * @see LocalBuildMemoryStorage * @see LocalNotificationClaimStrategy + * @see LocalEventClaimStrategy * @see CoordinationModeFactory */ // CHECKSTYLE:OFF MagicNumber - Ordinal must be literal in annotation, -1000 ensures fallback priority @@ -100,4 +103,15 @@ public BuildMemoryStorage createStorage() { public NotificationClaimStrategy createClaimStrategy() { return new LocalNotificationClaimStrategy(); } + + /** + * Creates a new local event claim strategy instance. + * Always succeeds and executes the action immediately - no coordination needed in standalone mode. + * + * @return a new LocalEventClaimStrategy + */ + @Override + public EventClaimStrategy createEventClaimStrategy() { + return new LocalEventClaimStrategy(); + } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java new file mode 100644 index 000000000..5e9366624 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java @@ -0,0 +1,85 @@ +/* + * The MIT License + * + * Copyright (c) 2010, 2014 Sony Mobile Communications Inc. All rights reserved. + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.map.EntryProcessor; + +import java.util.Map; + +/** + * Hazelcast EntryProcessor for atomically marking a build as cancelled. + * Executes on the partition owner to prevent race conditions. + * + * @author Robert Sandell <robert.sandell@sonyericsson.com> + */ +public class BuildCancelledProcessor implements EntryProcessor { + private static final long serialVersionUID = 1L; + + private final String projectFullName; + + /** + * Constructor. + * + * @param projectFullName the full name of the project + */ + public BuildCancelledProcessor(String projectFullName) { + this.projectFullName = projectFullName; + } + + @Override + public Boolean process(Map.Entry entry) { + MemoryImprintData data = entry.getValue(); + + // If no data exists, create it (shouldn't happen) + if (data == null) { + data = new MemoryImprintData(); + } + + // Find and update the entry for this project + boolean found = false; + if (data.getEntries() != null) { + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + entryData.setCancelled(true); + entryData.setBuildCompleted(true); // Cancelled builds are also completed + found = true; + break; + } + } + } + + // If project not found, add it + if (!found) { + EntryData newEntry = new EntryData(); + newEntry.setProjectFullName(projectFullName); + newEntry.setCancelled(true); + newEntry.setBuildCompleted(true); // Cancelled builds are also completed + data.addEntry(newEntry); + } + + // Save the modified data back atomically + entry.setValue(data); + return found; + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCompletedProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCompletedProcessor.java new file mode 100644 index 000000000..137c4ef93 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCompletedProcessor.java @@ -0,0 +1,95 @@ +/* + * The MIT License + * + * Copyright (c) 2010, 2014 Sony Mobile Communications Inc. All rights reserved. + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.map.EntryProcessor; + +import java.util.Map; + +/** + * Hazelcast EntryProcessor for atomically marking a build as completed. + * Executes on the partition owner to prevent race conditions when multiple + * replicas update the same event simultaneously. + * + * @author Robert Sandell <robert.sandell@sonyericsson.com> + */ +public class BuildCompletedProcessor implements EntryProcessor { + private static final long serialVersionUID = 1L; + + private final String projectFullName; + private final String buildId; + private final long timestamp; + + /** + * Constructor. + * + * @param projectFullName the full name of the project + * @param buildId the build ID + */ + public BuildCompletedProcessor(String projectFullName, String buildId) { + this.projectFullName = projectFullName; + this.buildId = buildId; + this.timestamp = System.currentTimeMillis(); + } + + @Override + public Boolean process(Map.Entry entry) { + MemoryImprintData data = entry.getValue(); + + // If no data exists, create it (shouldn't happen but handle gracefully) + if (data == null) { + data = new MemoryImprintData(); + } + + // Find and update the entry for this project + boolean found = false; + if (data.getEntries() != null) { + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + if (entryData.getBuildId() == null) { + entryData.setBuildId(buildId); + } + entryData.setBuildCompleted(true); + entryData.setCompletedTimestamp(timestamp); + found = true; + break; + } + } + } + + // If project not found, add it (build completed without being registered) + if (!found) { + EntryData newEntry = new EntryData(); + newEntry.setProjectFullName(projectFullName); + newEntry.setBuildId(buildId); + newEntry.setBuildCompleted(true); + newEntry.setCompletedTimestamp(timestamp); + data.addEntry(newEntry); + } + + // Save the modified data back atomically + entry.setValue(data); + return found; + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildMemoryKey.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildMemoryKey.java new file mode 100644 index 000000000..cf734c130 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildMemoryKey.java @@ -0,0 +1,92 @@ +/* + * The MIT License + * + * + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; +import java.io.Serializable; +import java.util.Objects; + +/** + * Key class for BuildMemory entries in Hazelcast. + *

+ * Uses event ID instead of event object for serialization efficiency. + * The event ID is deterministic (same event on different replicas produces same ID). + * + * @author CloudBees, Inc. + */ +public class BuildMemoryKey implements Serializable { + + private static final long serialVersionUID = 1L; + + private final String eventId; + + /** + * Constructor from GerritTriggeredEvent. + * + * @param event the Gerrit event + */ + public BuildMemoryKey(GerritTriggeredEvent event) { + this.eventId = EventIdentifier.generateEventId(event); + } + + /** + * Constructor from event ID string. + * + * @param eventId the event identifier + */ + public BuildMemoryKey(String eventId) { + this.eventId = eventId; + } + + /** + * Gets the event identifier. + * + * @return event ID + */ + public String getEventId() { + return eventId; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + BuildMemoryKey that = (BuildMemoryKey)o; + return Objects.equals(eventId, that.eventId); + } + + @Override + public int hashCode() { + return Objects.hash(eventId); + } + + @Override + public String toString() { + return "BuildMemoryKey{eventId='" + eventId + "'}"; + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildStartedProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildStartedProcessor.java new file mode 100644 index 000000000..a44a66428 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildStartedProcessor.java @@ -0,0 +1,90 @@ +/* + * The MIT License + * + * Copyright (c) 2010, 2014 Sony Mobile Communications Inc. All rights reserved. + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.map.EntryProcessor; + +import java.util.Map; + +/** + * Hazelcast EntryProcessor for atomically marking a build as started. + * Executes on the partition owner to prevent race conditions. + * + * @author Robert Sandell <robert.sandell@sonyericsson.com> + */ +public class BuildStartedProcessor implements EntryProcessor { + private static final long serialVersionUID = 1L; + + private final String projectFullName; + private final String buildId; + private final long timestamp; + + /** + * Constructor. + * + * @param projectFullName the full name of the project + * @param buildId the build ID + */ + public BuildStartedProcessor(String projectFullName, String buildId) { + this.projectFullName = projectFullName; + this.buildId = buildId; + this.timestamp = System.currentTimeMillis(); + } + + @Override + public Boolean process(Map.Entry entry) { + MemoryImprintData data = entry.getValue(); + + // If no data exists, create it (build started without being triggered) + if (data == null) { + data = new MemoryImprintData(); + } + + // Find and update the entry for this project + boolean found = false; + if (data.getEntries() != null) { + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + entryData.setBuildId(buildId); + entryData.setStartedTimestamp(timestamp); + found = true; + break; + } + } + } + + // If project not found, add it (build started without being triggered) + if (!found) { + EntryData newEntry = new EntryData(); + newEntry.setProjectFullName(projectFullName); + newEntry.setBuildId(buildId); + newEntry.setStartedTimestamp(timestamp); + data.addEntry(newEntry); + } + + // Save the modified data back atomically + entry.setValue(data); + return found; + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java new file mode 100644 index 000000000..231b3d4c0 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java @@ -0,0 +1,241 @@ +/* + * The MIT License + * + * + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import edu.umd.cs.findbugs.annotations.CheckForNull; + +/** + * Serializable data for BuildMemory Entry. + *

+ * Stores job and build information without Jenkins object references. + * Uses Compact Serialization for cross-JVM compatibility. + * + * @author CloudBees, Inc. + */ +public class EntryData { + + private String projectFullName; + private String buildId; + private boolean buildCompleted; + private boolean cancelled; + private String customUrl; + private String unsuccessfulMessage; + private long triggeredTimestamp; + private Long completedTimestamp; + private Long startedTimestamp; + + /** + * Default constructor. + */ + public EntryData() { + this.triggeredTimestamp = System.currentTimeMillis(); + } + + /** + * Constructor with parameters. + * + * @param projectFullName full job name + * @param buildId build identifier + * @param buildCompleted whether build is completed + */ + public EntryData(String projectFullName, String buildId, boolean buildCompleted) { + this.projectFullName = projectFullName; + this.buildId = buildId; + this.buildCompleted = buildCompleted; + this.triggeredTimestamp = System.currentTimeMillis(); + } + + /** + * Gets the project full name. + * + * @return project full name + */ + public String getProjectFullName() { + return projectFullName; + } + + /** + * Sets the project full name. + * + * @param projectFullName project full name + */ + public void setProjectFullName(String projectFullName) { + this.projectFullName = projectFullName; + } + + /** + * Gets the build ID. + * + * @return build ID + */ + @CheckForNull + public String getBuildId() { + return buildId; + } + + /** + * Sets the build ID. + * + * @param buildId build ID + */ + public void setBuildId(String buildId) { + this.buildId = buildId; + if (buildId != null && startedTimestamp == null) { + this.startedTimestamp = System.currentTimeMillis(); + } + } + + /** + * Checks if build is completed. + * + * @return true if completed + */ + public boolean isBuildCompleted() { + return buildCompleted; + } + + /** + * Sets build completed status. + * + * @param buildCompleted completed status + */ + public void setBuildCompleted(boolean buildCompleted) { + this.buildCompleted = buildCompleted; + if (buildCompleted && completedTimestamp == null) { + this.completedTimestamp = System.currentTimeMillis(); + } + } + + /** + * Checks if build was cancelled. + * + * @return true if cancelled + */ + public boolean isCancelled() { + return cancelled; + } + + /** + * Sets cancelled status. + * + * @param cancelled cancelled status + */ + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } + + /** + * Gets custom URL. + * + * @return custom URL + */ + @CheckForNull + public String getCustomUrl() { + return customUrl; + } + + /** + * Sets custom URL. + * + * @param customUrl custom URL + */ + public void setCustomUrl(String customUrl) { + this.customUrl = customUrl; + } + + /** + * Gets unsuccessful message. + * + * @return unsuccessful message + */ + @CheckForNull + public String getUnsuccessfulMessage() { + return unsuccessfulMessage; + } + + /** + * Sets unsuccessful message. + * + * @param unsuccessfulMessage unsuccessful message + */ + public void setUnsuccessfulMessage(String unsuccessfulMessage) { + this.unsuccessfulMessage = unsuccessfulMessage; + } + + /** + * Gets triggered timestamp. + * + * @return triggered timestamp + */ + public long getTriggeredTimestamp() { + return triggeredTimestamp; + } + + /** + * Sets triggered timestamp. + * + * @param triggeredTimestamp triggered timestamp + */ + public void setTriggeredTimestamp(long triggeredTimestamp) { + this.triggeredTimestamp = triggeredTimestamp; + } + + /** + * Gets completed timestamp. + * + * @return completed timestamp + */ + @CheckForNull + public Long getCompletedTimestamp() { + return completedTimestamp; + } + + /** + * Sets completed timestamp. + * + * @param completedTimestamp completed timestamp + */ + public void setCompletedTimestamp(Long completedTimestamp) { + this.completedTimestamp = completedTimestamp; + } + + /** + * Gets started timestamp. + * + * @return started timestamp + */ + @CheckForNull + public Long getStartedTimestamp() { + return startedTimestamp; + } + + /** + * Sets started timestamp. + * + * @param startedTimestamp started timestamp + */ + public void setStartedTimestamp(Long startedTimestamp) { + this.startedTimestamp = startedTimestamp; + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java new file mode 100644 index 000000000..a9ba22216 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java @@ -0,0 +1,86 @@ +/* + * The MIT License + * + * + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.nio.serialization.compact.CompactReader; +import com.hazelcast.nio.serialization.compact.CompactSerializer; +import com.hazelcast.nio.serialization.compact.CompactWriter; +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * Hazelcast Compact Serializer for {@link EntryData}. + *

+ * Serializes individual BuildMemory entries with fixed schema. + * + * @author CloudBees, Inc. + */ +public class EntryDataSerializer implements CompactSerializer { + + /** + * Type name for schema registration. + * Must be unique across all compact serialized types. + */ + private static final String TYPE_NAME = "EntryData"; + + @Override + @NonNull + public EntryData read(@NonNull CompactReader reader) { + EntryData entry = new EntryData(); + entry.setProjectFullName(reader.readString("projectFullName")); + entry.setBuildId(reader.readString("buildId")); + entry.setBuildCompleted(reader.readBoolean("buildCompleted")); + entry.setCancelled(reader.readBoolean("cancelled")); + entry.setCustomUrl(reader.readString("customUrl")); + entry.setUnsuccessfulMessage(reader.readString("unsuccessfulMessage")); + entry.setTriggeredTimestamp(reader.readInt64("triggeredTimestamp")); + entry.setCompletedTimestamp(reader.readNullableInt64("completedTimestamp")); + entry.setStartedTimestamp(reader.readNullableInt64("startedTimestamp")); + return entry; + } + + @Override + public void write(@NonNull CompactWriter writer, @NonNull EntryData entry) { + writer.writeString("projectFullName", entry.getProjectFullName()); + writer.writeString("buildId", entry.getBuildId()); + writer.writeBoolean("buildCompleted", entry.isBuildCompleted()); + writer.writeBoolean("cancelled", entry.isCancelled()); + writer.writeString("customUrl", entry.getCustomUrl()); + writer.writeString("unsuccessfulMessage", entry.getUnsuccessfulMessage()); + writer.writeInt64("triggeredTimestamp", entry.getTriggeredTimestamp()); + writer.writeNullableInt64("completedTimestamp", entry.getCompletedTimestamp()); + writer.writeNullableInt64("startedTimestamp", entry.getStartedTimestamp()); + } + + @Override + @NonNull + public String getTypeName() { + return TYPE_NAME; + } + + @Override + @NonNull + public Class getCompactClass() { + return EntryData.class; + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java new file mode 100644 index 000000000..2c8528a1f --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java @@ -0,0 +1,215 @@ +/* + * The MIT License + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.sonymobile.tools.gerrit.gerritevents.dto.attr.Change; +import com.sonymobile.tools.gerrit.gerritevents.dto.attr.PatchSet; +import com.sonymobile.tools.gerrit.gerritevents.dto.events.ChangeBasedEvent; +import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; +import com.sonymobile.tools.gerrit.gerritevents.dto.events.RefUpdated; + +/** + * Utility class for generating unique, consistent event identifiers. + *

+ * Event IDs are used for distributed event claiming in CloudBees HA/HS environments. + * The same Gerrit event arriving at different replicas must produce the same event ID + * to enable proper claim coordination. + *

+ * Event IDs are deterministic and based on immutable event properties such as: + *

    + *
  • Change number and patchset number (for change-based events)
  • + *
  • Project and ref name (for ref-updated events)
  • + *
  • Event type
  • + *
  • Server-side timestamp (eventCreatedOn from Gerrit server)
  • + *
+ *

+ * Important: Uses {@code eventCreatedOn} (server timestamp) rather than {@code receivedOn} + * (replica timestamp) to ensure identical event IDs across all replicas receiving the same event. + * + * @author CloudBees, Inc. + */ +public final class EventIdentifier { + + /** + * Length of short Git revision hash (first 8 characters). + */ + private static final int SHORT_REVISION_LENGTH = 8; + + /** + * Private constructor to prevent instantiation. + */ + private EventIdentifier() { + // Utility class + } + + /** + * Generates a unique identifier for a Gerrit event. + *

+ * The ID is deterministic - the same event on different replicas produces the same ID. + * This is critical for distributed event claiming to work correctly. + * + * @param event the Gerrit event + * @return unique event identifier + */ + public static String generateEventId(GerritTriggeredEvent event) { + if (event instanceof ChangeBasedEvent) { + return generateChangeBasedEventId((ChangeBasedEvent)event); + } + + if (event instanceof RefUpdated) { + return generateRefUpdatedEventId((RefUpdated)event); + } + + // Fallback for other event types + return generateFallbackEventId(event); + } + + /** + * Generates ID for change-based events (patchset-created, comment-added, etc.). + * + * @param event the change-based event + * @return event ID in format: change-{number}-{patchset}-{type}-{timestamp} + */ + private static String generateChangeBasedEventId(ChangeBasedEvent event) { + Change change = event.getChange(); + PatchSet patchSet = event.getPatchSet(); + + if (change == null || patchSet == null) { + return generateFallbackEventId(event); + } + + // Use server-side timestamp (eventCreatedOn) for consistency across replicas + // Fall back to receivedOn if eventCreatedOn is not available + long timestamp = getEventTimestamp(event); + + // Format: change---- + return String.format("change-%s-%s-%s-%d", + change.getNumber(), + patchSet.getNumber(), + sanitizeEventType(event.getEventType().getTypeValue()), + timestamp); + } + + /** + * Generates ID for ref-updated events. + * + * @param event the ref-updated event + * @return event ID in format: ref-{project}-{refName}-{shortRev}-{timestamp} + */ + private static String generateRefUpdatedEventId(RefUpdated event) { + if (event.getRefUpdate() == null) { + return generateFallbackEventId(event); + } + + String project = sanitize(event.getRefUpdate().getProject()); + String refName = sanitize(event.getRefUpdate().getRefName()); + String newRev = event.getRefUpdate().getNewRev(); + + // Use first 8 chars of revision (short hash) + String shortRev; + if (newRev != null && newRev.length() >= SHORT_REVISION_LENGTH) { + shortRev = newRev.substring(0, SHORT_REVISION_LENGTH); + } else { + shortRev = "unknown"; + } + + // Use server-side timestamp (eventCreatedOn) for consistency across replicas + // Fall back to receivedOn if eventCreatedOn is not available + long timestamp = getEventTimestamp(event); + + // Format: ref---- + return String.format("ref-%s-%s-%s-%d", + project, + refName, + shortRev, + timestamp); + } + + /** + * Generates fallback ID for events that don't match known patterns. + * + * @param event the event + * @return event ID in format: event-{type}-{timestamp}-{hash} + */ + private static String generateFallbackEventId(GerritTriggeredEvent event) { + // Use server-side timestamp (eventCreatedOn) for consistency across replicas + // Fall back to receivedOn if eventCreatedOn is not available + long timestamp = getEventTimestamp(event); + + // Format: event--- + // Hash provides uniqueness when timestamp alone isn't sufficient + return String.format("event-%s-%d-%08x", + sanitizeEventType(event.getEventType().getTypeValue()), + timestamp, + event.hashCode()); + } + + /** + * Gets the event timestamp, preferring server-side eventCreatedOn over replica-local receivedOn. + *

+ * This ensures that the same Gerrit event produces the same event ID across all Jenkins replicas, + * which is critical for distributed event claiming to work correctly. + *

+ * Falls back to receivedOn if eventCreatedOn is null (defensive programming for older Gerrit + * versions or events that don't populate this field). + * + * @param event the Gerrit event + * @return timestamp in milliseconds since epoch + */ + private static long getEventTimestamp(GerritTriggeredEvent event) { + if (event.getEventCreatedOn() != null) { + // Prefer server-side timestamp (same across all replicas) + return event.getEventCreatedOn().getTime(); + } + // Fallback to replica-local timestamp (may differ between replicas) + return event.getReceivedOn(); + } + + /** + * Sanitizes a string to be safe for use in event ID. + * Replaces special characters with underscores. + * + * @param input the input string + * @return sanitized string safe for use in identifiers + */ + private static String sanitize(String input) { + if (input == null) { + return "null"; + } + // Replace non-alphanumeric characters (except dash and underscore) with underscore + return input.replaceAll("[^a-zA-Z0-9_-]", "_"); + } + + /** + * Sanitizes event type string. + * Converts to lowercase and replaces spaces/special chars with dashes. + * + * @param eventType the event type + * @return sanitized event type + */ + private static String sanitizeEventType(String eventType) { + if (eventType == null) { + return "unknown"; + } + return eventType.toLowerCase().replaceAll("[^a-z0-9-]", "-"); + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java new file mode 100644 index 000000000..b6b8af4fa --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -0,0 +1,565 @@ +/* + * The MIT License + * + * Copyright 2010 Sony Ericsson Mobile Communications. All rights reserved. + * Copyright 2012 Sony Mobile Communications AB. All rights reserved. + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.map.IMap; +import com.sonyericsson.hudson.plugins.gerrit.trigger.diagnostics.BuildMemoryReport; +import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory.MemoryImprint; +import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildsStartedStats; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.BuildMemoryStorage; +import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; +import hudson.model.Job; +import hudson.model.Run; +import jenkins.model.Jenkins; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * Hazelcast-backed implementation of BuildMemoryStorage for HA/HS deployments. + *

+ * Uses distributed IMap for storing build memory across multiple Jenkins replicas. + * All operations use atomic EntryProcessor to prevent race conditions. + *

+ * This implementation is automatically selected when: + *

    + *
  • Coordination mode is set to 'hazelcast' via system property
  • + *
  • Hazelcast instance is available and running
  • + *
+ * + * @see HazelcastCoordinationProvider + * @author Robert Sandell <robert.sandell@sonyericsson.com> + */ +public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { + + private static final Logger logger = LoggerFactory.getLogger(HazelcastBuildMemoryStorage.class); + + /** + * Hazelcast map name for distributed build memory. + */ + private static final String MAP_NAME = "gerrit-trigger-build-memory"; + + /** + * Gson instance for JSON serialization of events. + */ + private static final Gson GSON = new GsonBuilder().create(); + + /** + * Distributed mode storage (coordination mode). + * Lazy-initialized when first accessed. + */ + private transient IMap distributedMemory = null; + + /** + * Gets or initializes the distributed memory map. + * + * @return distributed memory map, or null if Hazelcast unavailable + */ + private IMap getDistributedMemory() { + if (distributedMemory == null) { + HazelcastInstance hz = HazelcastInstanceProvider.getInstance(); + if (hz != null) { + distributedMemory = hz.getMap(MAP_NAME); + logger.debug("Initialized distributed BuildMemory map: {}", MAP_NAME); + } else { + logger.warn("Hazelcast unavailable, distributed memory not available"); + } + } + return distributedMemory; + } + + /** + * Serializes a GerritTriggeredEvent to JSON. + * + * @param event the event to serialize + * @return JSON string, or null if serialization fails + */ + private String serializeEvent(GerritTriggeredEvent event) { + try { + return GSON.toJson(event); + } catch (Exception e) { + logger.error("Failed to serialize event to JSON: " + event, e); + return null; + } + } + + /** + * Deserializes a GerritTriggeredEvent from JSON. + * + * @param eventJson the JSON string + * @return deserialized event, or null if deserialization fails + */ + private GerritTriggeredEvent deserializeEvent(String eventJson) { + try { + return GSON.fromJson(eventJson, GerritTriggeredEvent.class); + } catch (Exception e) { + logger.error("Failed to deserialize event from JSON", e); + return null; + } + } + + /** + * Reconstructs a MemoryImprint from distributed data. + * + * @param event the event + * @param data the serialized data + * @return reconstructed MemoryImprint + */ + private MemoryImprint reconstructMemoryImprint(GerritTriggeredEvent event, MemoryImprintData data) { + MemoryImprint imprint = new MemoryImprint(event); + + if (data.getEntries() != null) { + Jenkins jenkins = Jenkins.getInstanceOrNull(); + if (jenkins == null) { + logger.warn("Jenkins instance not available, cannot reconstruct MemoryImprint"); + return imprint; + } + + for (EntryData entryData : data.getEntries()) { + String projectFullName = entryData.getProjectFullName(); + Job project = jenkins.getItemByFullName(projectFullName, Job.class); + + if (project != null) { + if (entryData.getBuildId() != null) { + Run build = project.getBuild(entryData.getBuildId()); + if (build != null) { + imprint.set(project, build, entryData.isBuildCompleted()); + } else { + // Build not found, but project exists - add entry without build + imprint.set(project); + } + } else { + // No build ID - project triggered but not started + imprint.set(project); + } + + // Restore additional entry data + MemoryImprint.Entry entry = imprint.getEntry(project); + if (entry != null) { + entry.setCancelled(entryData.isCancelled()); + entry.setCustomUrl(entryData.getCustomUrl()); + entry.setUnsuccessfulMessage(entryData.getUnsuccessfulMessage()); + } + } + } + } + + return imprint; + } + + // ===== Implement BuildMemoryStorage abstract methods ===== + + @Override + @CheckForNull + public synchronized MemoryImprint getMemoryImprint(@NonNull GerritTriggeredEvent event) { + IMap map = getDistributedMemory(); + if (map == null) { + return null; + } + + BuildMemoryKey key = new BuildMemoryKey(event); + MemoryImprintData data = map.get(key); + if (data != null) { + return reconstructMemoryImprint(event, data); + } + return null; + } + + @Override + public synchronized void triggered(@NonNull GerritTriggeredEvent event, @NonNull Job project) { + IMap map = getDistributedMemory(); + if (map == null) { + logger.warn("Cannot record triggered - Hazelcast unavailable"); + return; + } + + BuildMemoryKey key = new BuildMemoryKey(event); + MemoryImprintData data = map.get(key); + + if (data == null) { + // Create new memory imprint data + data = new MemoryImprintData(); + data.setEventJson(serializeEvent(event)); + } + + // Add entry for triggered project + EntryData entryData = new EntryData(); + entryData.setProjectFullName(project.getFullName()); + data.addEntry(entryData); + + map.put(key, data); + logger.trace("Triggered event stored in distributed memory: {}", key); + } + + @Override + public synchronized void started(@NonNull GerritTriggeredEvent event, @NonNull Run build) { + IMap map = getDistributedMemory(); + if (map == null) { + logger.warn("Cannot mark started - Hazelcast unavailable"); + return; + } + + BuildMemoryKey key = new BuildMemoryKey(event); + String projectFullName = build.getParent().getFullName(); + String buildId = build.getId(); + + // ATOMIC OPERATION - Executes on partition owner, prevents race conditions + Boolean found = map.executeOnKey(key, new BuildStartedProcessor(projectFullName, buildId)); + + if (!found) { + logger.warn("Build started without being registered first (distributed mode)."); + } + logger.trace("Build started event stored in distributed memory: {}", key); + } + + @Override + public synchronized void completed(@NonNull GerritTriggeredEvent event, @NonNull Run build) { + IMap map = getDistributedMemory(); + if (map == null) { + logger.warn("Cannot mark completed - Hazelcast unavailable"); + return; + } + + BuildMemoryKey key = new BuildMemoryKey(event); + String projectFullName = build.getParent().getFullName(); + String buildId = build.getId(); + + // ATOMIC OPERATION - Executes on partition owner, prevents race conditions + Boolean found = map.executeOnKey(key, new BuildCompletedProcessor(projectFullName, buildId)); + + if (!found) { + logger.debug("Build completed without being registered first (distributed mode)."); + } + logger.trace("Build completed event stored in distributed memory: {}", key); + } + + @Override + public synchronized void retriggered(@NonNull GerritTriggeredEvent event, @NonNull Job project, + @CheckForNull List otherBuilds) { + IMap map = getDistributedMemory(); + if (map == null) { + logger.warn("Cannot record retriggered - Hazelcast unavailable"); + return; + } + + BuildMemoryKey key = new BuildMemoryKey(event); + MemoryImprintData data = map.get(key); + + if (data == null) { + // Create new memory imprint data + data = new MemoryImprintData(); + data.setEventJson(serializeEvent(event)); + + if (otherBuilds != null) { + // Populate with old build info + for (Run build : otherBuilds) { + EntryData entryData = new EntryData(); + entryData.setProjectFullName(build.getParent().getFullName()); + entryData.setBuildId(build.getId()); + entryData.setBuildCompleted(!build.isBuilding()); + data.addEntry(entryData); + } + } + } + + // Reset the retriggered project (clear build info) + String projectFullName = project.getFullName(); + boolean found = false; + + if (data.getEntries() != null) { + for (EntryData entry : data.getEntries()) { + if (projectFullName.equals(entry.getProjectFullName())) { + // Reset this entry + entry.setBuildId(null); + entry.setBuildCompleted(false); + entry.setStartedTimestamp(null); + entry.setCompletedTimestamp(null); + found = true; + break; + } + } + } + + if (!found) { + // Add new entry for retriggered project + EntryData entryData = new EntryData(); + entryData.setProjectFullName(projectFullName); + data.addEntry(entryData); + } + + map.put(key, data); + logger.trace("Retriggered event stored in distributed memory: {}", key); + } + + @Override + public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull Job project) { + IMap map = getDistributedMemory(); + if (map == null) { + logger.warn("Cannot mark cancelled - Hazelcast unavailable"); + return; + } + + BuildMemoryKey key = new BuildMemoryKey(event); + String projectFullName = project.getFullName(); + + // ATOMIC OPERATION - Executes on partition owner, prevents race conditions + Boolean found = map.executeOnKey(key, new BuildCancelledProcessor(projectFullName)); + + if (!found) { + logger.debug("Build cancelled without being registered first (distributed mode)."); + } + logger.trace("Cancelled event stored in distributed memory: {}", key); + } + + @Override + public synchronized void forget(@NonNull GerritTriggeredEvent event) { + IMap map = getDistributedMemory(); + if (map == null) { + return; + } + + BuildMemoryKey key = new BuildMemoryKey(event); + map.remove(key); + logger.trace("Forgot event from distributed memory: {}", key); + } + + @Override + public synchronized void removeProject(@NonNull Job project) { + String projectFullName = project.getFullName(); + + IMap map = getDistributedMemory(); + if (map == null) { + return; + } + + // Iterate over all entries in distributed memory + for (Map.Entry mapEntry : map.entrySet()) { + MemoryImprintData data = mapEntry.getValue(); + if (data.getEntries() != null) { + // Remove entries matching this project + boolean removed = data.getEntries().removeIf( + entry -> projectFullName.equals(entry.getProjectFullName()) + ); + + // If we removed anything, update the map + if (removed) { + map.put(mapEntry.getKey(), data); + logger.trace("Removed project {} from distributed memory entry: {}", + projectFullName, mapEntry.getKey()); + } + } + } + } + + @Override + public synchronized boolean isAllBuildsCompleted(@NonNull GerritTriggeredEvent event) { + MemoryImprint imprint = getMemoryImprint(event); + return imprint != null && imprint.isAllBuildsCompleted(); + } + + @Override + public synchronized boolean isAllBuildsStarted(@NonNull GerritTriggeredEvent event) { + MemoryImprint imprint = getMemoryImprint(event); + return imprint != null && imprint.isAllBuildsSet(); + } + + @Override + @CheckForNull + public synchronized BuildsStartedStats getBuildsStartedStats(@NonNull GerritTriggeredEvent event) { + MemoryImprint imprint = getMemoryImprint(event); + if (imprint != null) { + return imprint.getBuildsStartedStats(); + } + return null; + } + + @Override + @CheckForNull + public synchronized String getStatusReport(@NonNull GerritTriggeredEvent event) { + MemoryImprint imprint = getMemoryImprint(event); + if (imprint != null) { + return imprint.getStatusReport(); + } + return null; + } + + @Override + public synchronized boolean isTriggered(@NonNull GerritTriggeredEvent event, @NonNull Job project) { + MemoryImprint imprint = getMemoryImprint(event); + if (imprint == null) { + return false; + } + String fullName = project.getFullName(); + for (MemoryImprint.Entry entry : imprint.getEntries()) { + if (entry.isProject(fullName)) { + return true; + } + } + return false; + } + + @Override + public synchronized boolean isBuilding(@NonNull GerritTriggeredEvent event, @NonNull Job project) { + MemoryImprint imprint = getMemoryImprint(event); + if (imprint == null) { + return false; + } + String fullName = project.getFullName(); + for (MemoryImprint.Entry entry : imprint.getEntries()) { + if (entry.isProject(fullName)) { + if (entry.getBuild() != null) { + return !entry.isBuildCompleted(); + } else { + return !entry.isCancelling() && !entry.isCancelled(); + } + } + } + return false; + } + + @Override + public synchronized boolean isBuilding(@NonNull GerritTriggeredEvent event) { + MemoryImprint imprint = getMemoryImprint(event); + return imprint != null; + } + + @Override + @CheckForNull + public synchronized List getBuilds(@NonNull GerritTriggeredEvent event) { + MemoryImprint imprint = getMemoryImprint(event); + if (imprint != null) { + List list = new LinkedList<>(); + for (MemoryImprint.Entry entry : imprint.getEntries()) { + if (entry.getBuild() != null) { + list.add(entry.getBuild()); + } + } + return list; + } + return null; + } + + @Override + public void setEntryCustomUrl(@NonNull GerritTriggeredEvent event, @NonNull Run r, + @CheckForNull String customUrl) { + IMap map = getDistributedMemory(); + if (map == null) { + logger.warn("Cannot set custom URL - Hazelcast unavailable"); + return; + } + + BuildMemoryKey key = new BuildMemoryKey(event); + String projectFullName = r.getParent().getFullName(); + + // ATOMIC OPERATION - Executes on partition owner, prevents race conditions + Boolean found = map.executeOnKey(key, new SetCustomUrlProcessor(projectFullName, customUrl)); + + if (found) { + logger.trace("Recording custom URL for {}: {}", event, customUrl); + } else { + logger.warn("Could not set custom URL - event not found: {}", event); + } + } + + @Override + public void setEntryUnsuccessfulMessage(@NonNull GerritTriggeredEvent event, @NonNull Run r, + @CheckForNull String unsuccessfulMessage) { + IMap map = getDistributedMemory(); + if (map == null) { + logger.warn("Cannot set unsuccessful message - Hazelcast unavailable"); + return; + } + + BuildMemoryKey key = new BuildMemoryKey(event); + String projectFullName = r.getParent().getFullName(); + + // ATOMIC OPERATION - Executes on partition owner, prevents race conditions + Boolean found = map.executeOnKey(key, + new SetUnsuccessfulMessageProcessor(projectFullName, unsuccessfulMessage)); + + if (found) { + logger.trace("Recording unsuccessful message for {}: {}", event, unsuccessfulMessage); + } else { + logger.warn("Could not set unsuccessful message - event not found: {}", event); + } + } + + @Override + @NonNull + public synchronized BuildMemoryReport report() { + BuildMemoryReport report = new BuildMemoryReport(); + + IMap map = getDistributedMemory(); + if (map == null) { + return report; + } + + // Read all entries from distributed memory + for (Map.Entry mapEntry : map.entrySet()) { + MemoryImprintData data = mapEntry.getValue(); + GerritTriggeredEvent event = deserializeEvent(data.getEventJson()); + + if (event != null) { + MemoryImprint imprint = reconstructMemoryImprint(event, data); + List triggered = new LinkedList(); + for (MemoryImprint.Entry tr : imprint.getEntries()) { + triggered.add(tr.clone()); + } + report.put(event, triggered); + } + } + return report; + } + + @Override + @NonNull + public synchronized Map getAllEvents() { + Map result = new HashMap<>(); + + IMap map = getDistributedMemory(); + if (map == null) { + return result; + } + + // Convert all entries + for (Map.Entry entry : map.entrySet()) { + GerritTriggeredEvent event = deserializeEvent(entry.getValue().getEventJson()); + if (event != null) { + MemoryImprint imprint = reconstructMemoryImprint(event, entry.getValue()); + result.put(event, imprint); + } + } + return result; + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java new file mode 100644 index 000000000..cd994e0e5 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java @@ -0,0 +1,314 @@ +/* + * The MIT License + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.config.Config; +import com.hazelcast.config.JoinConfig; +import com.hazelcast.config.NetworkConfig; +import jenkins.model.Jenkins; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Configuration builder for Hazelcast cluster. + * Creates appropriate configuration based on deployment environment (Kubernetes, TCP/IP, etc.). + * + * @author CloudBees, Inc. + */ +public final class HazelcastConfig { + + private static final Logger logger = LoggerFactory.getLogger(HazelcastConfig.class); + + /** + * Default cluster name for Gerrit Trigger plugin Hazelcast cluster. + */ + public static final String DEFAULT_CLUSTER_NAME = "gerrit-trigger-cluster"; + + /** + * Default Hazelcast port. + */ + public static final int DEFAULT_PORT = 5702; + + /** + * Default number of ports to try for auto-increment. + */ + public static final int DEFAULT_PORT_COUNT = 10; + + /** + * Default operation call timeout in milliseconds. + */ + public static final String DEFAULT_OPERATION_TIMEOUT = "30000"; + + /** + * System property to specify cluster name. + * Default: "gerrit-trigger-cluster" + */ + public static final String CLUSTER_NAME_PROPERTY = "gerrit.trigger.coordination.hazelcast.cluster.name"; + + /** + * System property to specify Hazelcast port. + * Default: 5702 + */ + public static final String PORT_PROPERTY = "gerrit.trigger.coordination.hazelcast.port"; + + /** + * System property to specify number of ports to try for auto-increment. + * Default: 10 + */ + public static final String PORT_COUNT_PROPERTY = "gerrit.trigger.coordination.hazelcast.port.count"; + + /** + * System property to specify operation call timeout in milliseconds. + * Default: 30000 (30 seconds) + */ + public static final String OPERATION_TIMEOUT_PROPERTY = "gerrit.trigger.coordination.hazelcast.operation.timeout"; + + /** + * System property to specify discovery mode. + * Values: "kubernetes", "tcp", "multicast" (for testing only). + */ + public static final String DISCOVERY_MODE_PROPERTY = "gerrit.trigger.coordination.hazelcast.discovery.mode"; + + /** + * System property to specify Kubernetes service name. + * Default: "jenkins" + */ + public static final String K8S_SERVICE_NAME_PROPERTY = "gerrit.trigger.coordination.hazelcast.k8s.service.name"; + + /** + * System property to specify Kubernetes namespace. + * Default: "default" + */ + public static final String K8S_NAMESPACE_PROPERTY = "gerrit.trigger.coordination.hazelcast.k8s.namespace"; + + /** + * System property to specify TCP/IP members (comma-separated). + * Example: "replica-0.jenkins:5701,replica-1.jenkins:5701" + */ + public static final String TCP_MEMBERS_PROPERTY = "gerrit.trigger.coordination.hazelcast.tcp.members"; + + /** + * Private constructor to prevent instantiation. + */ + private HazelcastConfig() { + // Utility class + } + + /** + * Creates a Hazelcast configuration suitable for the current environment. + * + * @return configured Hazelcast Config object + */ + public static Config createConfig() { + Config config = new Config(); + + // Set cluster name (configurable via system property) + String clusterName = System.getProperty(CLUSTER_NAME_PROPERTY, DEFAULT_CLUSTER_NAME); + config.setClusterName(clusterName); + logger.info("Hazelcast cluster name: {}", clusterName); + + // Set instance name (includes Jenkins URL for identification) + String instanceName = generateInstanceName(); + config.setInstanceName(instanceName); + logger.info("Hazelcast instance name: {}", instanceName); + + // Configure network and discovery + configureNetwork(config); + + // Configure settings (all configurable via system properties) + config.setProperty("hazelcast.logging.type", "slf4j"); + config.setProperty("hazelcast.shutdownhook.enabled", "false"); // We manage shutdown + + String operationTimeout = System.getProperty(OPERATION_TIMEOUT_PROPERTY, DEFAULT_OPERATION_TIMEOUT); + config.setProperty("hazelcast.operation.call.timeout.millis", operationTimeout); + logger.info("Hazelcast operation timeout: {} ms", operationTimeout); + + // Register Compact Serializers for event claiming and build memory + // This enables cross-JVM serialization compatibility with sidecar deployment + // Note: EventClaimSerializer will be added in Phase 6 when EventClaim is ported + config.getSerializationConfig() + .getCompactSerializationConfig() + .addSerializer(new EntryDataSerializer()) + .addSerializer(new MemoryImprintDataSerializer()); + logger.debug("Registered Compact Serializers for EntryData and MemoryImprintData"); + + logger.info("Hazelcast configuration created for cluster: {}", clusterName); + + return config; + } + + /** + * Configures network settings and discovery mechanism. + * + * @param config the Hazelcast config to configure + */ + private static void configureNetwork(Config config) { + NetworkConfig networkConfig = config.getNetworkConfig(); + + // Set port (configurable via system property) + int port = Integer.parseInt(System.getProperty(PORT_PROPERTY, String.valueOf(DEFAULT_PORT))); + networkConfig.setPort(port); + networkConfig.setPortAutoIncrement(true); + + // Set port count (configurable via system property) + int portCount = Integer.parseInt(System.getProperty(PORT_COUNT_PROPERTY, String.valueOf(DEFAULT_PORT_COUNT))); + networkConfig.setPortCount(portCount); + + logger.info("Hazelcast network: port={}, portCount={} (will try ports {}-{})", + port, portCount, port, port + portCount - 1); + + JoinConfig joinConfig = networkConfig.getJoin(); + + // Determine discovery mode + String discoveryMode = System.getProperty(DISCOVERY_MODE_PROPERTY, "auto"); + logger.info("Hazelcast discovery mode: {}", discoveryMode); + + if ("kubernetes".equalsIgnoreCase(discoveryMode) || isKubernetesEnvironment()) { + configureKubernetesDiscovery(joinConfig, port); + } else if ("tcp".equalsIgnoreCase(discoveryMode) || hasTcpMembersConfigured()) { + configureTcpDiscovery(joinConfig); + } else { + // Fallback: Try Kubernetes, then TCP + logger.info("Auto-detecting discovery mechanism..."); + if (isKubernetesEnvironment()) { + configureKubernetesDiscovery(joinConfig, port); + } else { + configureTcpDiscovery(joinConfig); + } + } + + // Always disable multicast (not suitable for production) + joinConfig.getMulticastConfig().setEnabled(false); + } + + /** + * Configures Kubernetes discovery. + * + * @param joinConfig the join configuration + * @param port the Hazelcast port to discover (filters out other Hazelcast instances on different ports) + */ + private static void configureKubernetesDiscovery(JoinConfig joinConfig, int port) { + String serviceName = System.getProperty(K8S_SERVICE_NAME_PROPERTY, "jenkins"); + String namespace = System.getProperty(K8S_NAMESPACE_PROPERTY, "default"); + + logger.info("Configuring Kubernetes discovery: service={}, namespace={}, port={}", + serviceName, namespace, port); + + joinConfig.getKubernetesConfig() + .setEnabled(true) + .setProperty("service-name", serviceName) + .setProperty("namespace", namespace) + .setProperty("service-port", String.valueOf(port)); + + // Disable other discovery methods + joinConfig.getTcpIpConfig().setEnabled(false); + joinConfig.getAwsConfig().setEnabled(false); + joinConfig.getAzureConfig().setEnabled(false); + } + + /** + * Configures TCP/IP discovery with static member list. + * + * @param joinConfig the join configuration + */ + private static void configureTcpDiscovery(JoinConfig joinConfig) { + String tcpMembers = System.getProperty(TCP_MEMBERS_PROPERTY, ""); + + if (tcpMembers.isEmpty()) { + logger.warn("TCP discovery mode selected but no members configured. " + + "Set {} system property.", TCP_MEMBERS_PROPERTY); + logger.warn("Example: -D{}=replica-0.jenkins:5701,replica-1.jenkins:5701", + TCP_MEMBERS_PROPERTY); + // Use localhost as fallback for single-instance testing + tcpMembers = "localhost:5701"; + } + + logger.info("Configuring TCP/IP discovery with members: {}", tcpMembers); + + joinConfig.getTcpIpConfig() + .setEnabled(true) + .addMember(tcpMembers); + + // Disable other discovery methods + joinConfig.getKubernetesConfig().setEnabled(false); + joinConfig.getAwsConfig().setEnabled(false); + joinConfig.getAzureConfig().setEnabled(false); + } + + /** + * Checks if running in Kubernetes environment. + * + * @return true if Kubernetes environment detected + */ + private static boolean isKubernetesEnvironment() { + // Check for Kubernetes service account token + return System.getenv("KUBERNETES_SERVICE_HOST") != null; + } + + /** + * Checks if TCP members are configured via system property. + * + * @return true if TCP members property is set + */ + private static boolean hasTcpMembersConfigured() { + String tcpMembers = System.getProperty(TCP_MEMBERS_PROPERTY); + return tcpMembers != null && !tcpMembers.trim().isEmpty(); + } + + /** + * Generates a unique instance name for this Hazelcast member. + * Includes Jenkins URL and hostname for identification. + * + * @return instance name + */ + private static String generateInstanceName() { + StringBuilder name = new StringBuilder("gerrit-trigger"); + + // Add Jenkins root URL if available + Jenkins jenkins = Jenkins.getInstanceOrNull(); + if (jenkins != null) { + String rootUrl = jenkins.getRootUrl(); + if (rootUrl != null && !rootUrl.isEmpty()) { + // Extract hostname from URL + try { + java.net.URI uri = new java.net.URI(rootUrl); + String host = uri.getHost(); + if (host != null) { + name.append("-").append(host.replace('.', '-')); + } + } catch (Exception e) { + logger.debug("Could not parse Jenkins root URL: {}", rootUrl, e); + } + } + } + + // Add hostname + try { + String hostname = java.net.InetAddress.getLocalHost().getHostName(); + name.append("-").append(hostname.replace('.', '-')); + } catch (Exception e) { + logger.debug("Could not determine hostname", e); + } + + return name.toString(); + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastInstanceProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastInstanceProvider.java new file mode 100644 index 000000000..0e20b40d0 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastInstanceProvider.java @@ -0,0 +1,159 @@ +/* + * The MIT License + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.core.HazelcastInstance; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Singleton provider for the Hazelcast instance. + *

+ * Provides thread-safe access to the Hazelcast embedded member instance. + * The instance is set by {@link HazelcastManager} during initialization. + * + * @author CloudBees, Inc. + */ +public final class HazelcastInstanceProvider { + + private static final Logger logger = LoggerFactory.getLogger(HazelcastInstanceProvider.class); + + private static volatile HazelcastInstance instance; + private static final Object LOCK = new Object(); + + /** + * Private constructor to prevent instantiation. + */ + private HazelcastInstanceProvider() { + // Singleton + } + + /** + * Sets the Hazelcast instance. + * Should only be called by HazelcastManager during initialization. + * + * @param hazelcastInstance the Hazelcast instance to set + * @throws IllegalStateException if instance is already set + */ + static void setInstance(@NonNull HazelcastInstance hazelcastInstance) { + synchronized (LOCK) { + if (instance != null) { + throw new IllegalStateException("Hazelcast instance is already set. " + + "Call clearInstance() before setting a new instance."); + } + instance = hazelcastInstance; + logger.info("Hazelcast instance set: {}", hazelcastInstance.getName()); + } + } + + /** + * Clears the Hazelcast instance. + * Should only be called by HazelcastManager during shutdown. + */ + static void clearInstance() { + synchronized (LOCK) { + if (instance != null) { + logger.info("Clearing Hazelcast instance: {}", instance.getName()); + instance = null; + } + } + } + + /** + * Gets the Hazelcast instance. + * + * @return the Hazelcast instance, or null if not initialized + */ + @CheckForNull + public static HazelcastInstance getInstance() { + return instance; + } + + /** + * Gets the Hazelcast instance, throwing an exception if not initialized. + * + * @return the Hazelcast instance + * @throws IllegalStateException if Hazelcast is not initialized + */ + @NonNull + public static HazelcastInstance getInstanceOrThrow() { + HazelcastInstance hz = instance; + if (hz == null) { + throw new IllegalStateException("Hazelcast instance is not initialized. " + + "Ensure coordination mode is 'hazelcast' and Hazelcast has been started."); + } + return hz; + } + + /** + * Checks if the Hazelcast instance is initialized. + * + * @return true if instance is initialized and running + */ + public static boolean isInitialized() { + HazelcastInstance hz = instance; + return hz != null && hz.getLifecycleService().isRunning(); + } + + /** + * Gets the cluster name if Hazelcast is initialized. + * + * @return cluster name, or null if not initialized + */ + @CheckForNull + public static String getClusterName() { + HazelcastInstance hz = instance; + if (hz != null) { + return hz.getConfig().getClusterName(); + } + return null; + } + + /** + * Gets the instance name if Hazelcast is initialized. + * + * @return instance name, or null if not initialized + */ + @CheckForNull + public static String getInstanceName() { + HazelcastInstance hz = instance; + if (hz != null) { + return hz.getName(); + } + return null; + } + + /** + * Gets the number of members in the cluster. + * + * @return member count, or 0 if not initialized + */ + public static int getClusterSize() { + HazelcastInstance hz = instance; + if (hz != null) { + return hz.getCluster().getMembers().size(); + } + return 0; + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java new file mode 100644 index 000000000..7c8ccc164 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java @@ -0,0 +1,204 @@ +/* + * The MIT License + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.core.Hazelcast; +import com.hazelcast.core.HazelcastInstance; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Manages the lifecycle of Hazelcast embedded member. + *

+ * This manager handles initialization and shutdown of the Hazelcast instance. + * Whether to initialize is determined by {@link HazelcastCoordinationProvider#isAvailable()}, + * not by this class. + * + * @author CloudBees, Inc. + */ +public final class HazelcastManager { + + private static final Logger logger = LoggerFactory.getLogger(HazelcastManager.class); + + private static volatile boolean initialized = false; + private static final Object INIT_LOCK = new Object(); + + /** + * Private constructor to prevent instantiation. + */ + private HazelcastManager() { + // Utility class + } + + /** + * Initializes Hazelcast embedded member. + *

+ * Creates a Hazelcast member in the Jenkins JVM with configuration from + * {@link HazelcastConfig#createConfig()}. + *

+ * This method is idempotent - calling it multiple times has no effect if already initialized. + * + * @return true if Hazelcast was initialized (or already initialized) + * @throws RuntimeException if initialization fails + */ + public static boolean initialize() { + synchronized (INIT_LOCK) { + if (initialized) { + logger.debug("Hazelcast is already initialized"); + return true; + } + + try { + logger.info("Initializing Hazelcast embedded member..."); + + // Create Hazelcast configuration + com.hazelcast.config.Config config = HazelcastConfig.createConfig(); + + // Create Hazelcast instance + HazelcastInstance hazelcastInstance = Hazelcast.newHazelcastInstance(config); + + // Register with provider + HazelcastInstanceProvider.setInstance(hazelcastInstance); + + initialized = true; + + // Log cluster information + int clusterSize = hazelcastInstance.getCluster().getMembers().size(); + logger.info("Hazelcast embedded member initialized. Cluster: {}, Instance: {}, Members: {}", + config.getClusterName(), + hazelcastInstance.getName(), + clusterSize); + + return true; + + } catch (Exception e) { + logger.error("Failed to initialize Hazelcast", e); + initialized = false; + throw new RuntimeException("Failed to initialize Hazelcast", e); + } + } + } + + /** + * Shuts down Hazelcast gracefully. + *

+ * Shuts down the Hazelcast embedded member and cleans up resources. + *

+ * This method is idempotent - calling it multiple times has no effect if already shut down. + */ + public static void shutdown() { + synchronized (INIT_LOCK) { + if (!initialized) { + logger.debug("Hazelcast is not initialized, nothing to shut down"); + return; + } + + try { + logger.info("Shutting down Hazelcast embedded member..."); + + HazelcastInstance instance = HazelcastInstanceProvider.getInstance(); + if (instance != null) { + String instanceName = instance.getName(); + + // Shutdown the instance + instance.shutdown(); + + logger.info("Hazelcast embedded member shut down: {}", instanceName); + } + + // Clear the provider + HazelcastInstanceProvider.clearInstance(); + + initialized = false; + + logger.info("Hazelcast shutdown complete"); + + } catch (Exception e) { + logger.error("Error during Hazelcast shutdown", e); + // Continue with cleanup even if error occurred + HazelcastInstanceProvider.clearInstance(); + initialized = false; + } + } + } + + /** + * Checks if Hazelcast is currently initialized. + * + * @return true if initialized + */ + public static boolean isInitialized() { + return initialized && HazelcastInstanceProvider.isInitialized(); + } + + /** + * Reinitializes Hazelcast. + * This will shutdown the existing instance and create a new one. + * Used when configuration has changed. + * + * @return true if reinitialized successfully + */ + public static boolean reinitialize() { + logger.info("Reinitializing Hazelcast..."); + + synchronized (INIT_LOCK) { + // Shutdown existing instance + if (initialized) { + shutdown(); + } + + // Initialize new instance + return initialize(); + } + } + + /** + * Gets status information about Hazelcast cluster. + * + * @return status string with cluster information + */ + public static String getStatus() { + if (!initialized) { + return "Hazelcast: Not initialized"; + } + + HazelcastInstance instance = HazelcastInstanceProvider.getInstance(); + if (instance == null) { + return "Hazelcast: Error - initialized flag is true but instance is null"; + } + + if (!instance.getLifecycleService().isRunning()) { + return "Hazelcast: Not running"; + } + + try { + int clusterSize = instance.getCluster().getMembers().size(); + String clusterName = instance.getConfig().getClusterName(); + String instanceName = instance.getName(); + + return String.format("Hazelcast: Running | Cluster: %s | Instance: %s | Members: %d", + clusterName, instanceName, clusterSize); + } catch (Exception e) { + return String.format("Hazelcast: Error getting status: %s", e.getMessage()); + } + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintData.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintData.java new file mode 100644 index 000000000..8e1e2036c --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintData.java @@ -0,0 +1,108 @@ +/* + * The MIT License + * + * + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import java.util.ArrayList; +import java.util.List; + +/** + * Serializable data for MemoryImprint to store in Hazelcast. + *

+ * Contains simplified Entry data without complex object references. + * Uses Compact Serialization for cross-JVM compatibility in sidecar deployments. + * + * @author CloudBees, Inc. + */ +public class MemoryImprintData { + + private String eventJson; // JSON representation of GerritTriggeredEvent + private List entries; + + /** + * Default constructor. + */ + public MemoryImprintData() { + this.entries = new ArrayList<>(); + } + + /** + * Constructor with parameters. + * + * @param eventJson serialized event + * @param entries list of entry data + */ + public MemoryImprintData(String eventJson, List entries) { + this.eventJson = eventJson; + if (entries != null) { + this.entries = entries; + } else { + this.entries = new ArrayList<>(); + } + } + + /** + * Gets the serialized event JSON. + * + * @return event JSON string + */ + public String getEventJson() { + return eventJson; + } + + /** + * Sets the serialized event JSON. + * + * @param eventJson event JSON string + */ + public void setEventJson(String eventJson) { + this.eventJson = eventJson; + } + + /** + * Gets the list of entries. + * + * @return list of entry data + */ + public List getEntries() { + return entries; + } + + /** + * Sets the list of entries. + * + * @param entries list of entry data + */ + public void setEntries(List entries) { + this.entries = entries; + } + + /** + * Adds an entry to the list. + * + * @param entry the entry to add + */ + public void addEntry(EntryData entry) { + this.entries.add(entry); + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintDataSerializer.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintDataSerializer.java new file mode 100644 index 000000000..2af8e5a0b --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintDataSerializer.java @@ -0,0 +1,92 @@ +/* + * The MIT License + * + * + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.nio.serialization.compact.CompactReader; +import com.hazelcast.nio.serialization.compact.CompactSerializer; +import com.hazelcast.nio.serialization.compact.CompactWriter; +import edu.umd.cs.findbugs.annotations.NonNull; + +import java.util.ArrayList; +import java.util.List; + +/** + * Hazelcast Compact Serializer for {@link MemoryImprintData}. + *

+ * Compact Serialization is schema-based and doesn't require class definitions + * on the Hazelcast server (sidecar container). This enables cross-JVM serialization + * without classloading issues. + * + * @author CloudBees, Inc. + */ +public class MemoryImprintDataSerializer implements CompactSerializer { + + /** + * Type name for schema registration. + * Must be unique across all compact serialized types. + */ + private static final String TYPE_NAME = "MemoryImprintData"; + + @Override + @NonNull + public MemoryImprintData read(@NonNull CompactReader reader) { + String eventJson = reader.readString("eventJson"); + + // Read entries array using Compact Serialization array support + EntryData[] entriesArray = reader.readArrayOfCompact("entries", EntryData.class); + List entries = new ArrayList<>(); + if (entriesArray != null) { + for (EntryData entry : entriesArray) { + entries.add(entry); + } + } + + return new MemoryImprintData(eventJson, entries); + } + + @Override + public void write(@NonNull CompactWriter writer, @NonNull MemoryImprintData data) { + writer.writeString("eventJson", data.getEventJson()); + + // Write entries array using Compact Serialization array support + List entries = data.getEntries(); + EntryData[] entriesArray = null; + if (entries != null && !entries.isEmpty()) { + entriesArray = entries.toArray(new EntryData[0]); + } + writer.writeArrayOfCompact("entries", entriesArray); + } + + @Override + @NonNull + public String getTypeName() { + return TYPE_NAME; + } + + @Override + @NonNull + public Class getCompactClass() { + return MemoryImprintData.class; + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCustomUrlProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCustomUrlProcessor.java new file mode 100644 index 000000000..1f02f63f0 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCustomUrlProcessor.java @@ -0,0 +1,72 @@ +/* + * The MIT License + * + * Copyright (c) 2010, 2014 Sony Mobile Communications Inc. All rights reserved. + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.map.EntryProcessor; + +import java.util.Map; + +/** + * Hazelcast EntryProcessor for atomically setting a custom URL for a build. + * Executes on the partition owner to prevent race conditions. + * + * @author Robert Sandell <robert.sandell@sonyericsson.com> + */ +public class SetCustomUrlProcessor implements EntryProcessor { + private static final long serialVersionUID = 1L; + + private final String projectFullName; + private final String customUrl; + + /** + * Constructor. + * + * @param projectFullName the full name of the project + * @param customUrl the custom URL to set + */ + public SetCustomUrlProcessor(String projectFullName, String customUrl) { + this.projectFullName = projectFullName; + this.customUrl = customUrl; + } + + @Override + public Boolean process(Map.Entry entry) { + MemoryImprintData data = entry.getValue(); + if (data == null || data.getEntries() == null) { + return false; + } + + // Find and update the entry for this project + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + entryData.setCustomUrl(customUrl); + // Save the modified data back atomically + entry.setValue(data); + return true; + } + } + + return false; + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetUnsuccessfulMessageProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetUnsuccessfulMessageProcessor.java new file mode 100644 index 000000000..63995890f --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetUnsuccessfulMessageProcessor.java @@ -0,0 +1,72 @@ +/* + * The MIT License + * + * Copyright (c) 2010, 2014 Sony Mobile Communications Inc. All rights reserved. + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.map.EntryProcessor; + +import java.util.Map; + +/** + * Hazelcast EntryProcessor for atomically setting an unsuccessful message for a build. + * Executes on the partition owner to prevent race conditions. + * + * @author Robert Sandell <robert.sandell@sonyericsson.com> + */ +public class SetUnsuccessfulMessageProcessor implements EntryProcessor { + private static final long serialVersionUID = 1L; + + private final String projectFullName; + private final String unsuccessfulMessage; + + /** + * Constructor. + * + * @param projectFullName the full name of the project + * @param unsuccessfulMessage the unsuccessful message to set + */ + public SetUnsuccessfulMessageProcessor(String projectFullName, String unsuccessfulMessage) { + this.projectFullName = projectFullName; + this.unsuccessfulMessage = unsuccessfulMessage; + } + + @Override + public Boolean process(Map.Entry entry) { + MemoryImprintData data = entry.getValue(); + if (data == null || data.getEntries() == null) { + return false; + } + + // Find and update the entry for this project + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + entryData.setUnsuccessfulMessage(unsuccessfulMessage); + // Save the modified data back atomically + entry.setValue(data); + return true; + } + } + + return false; + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalEventClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalEventClaimStrategy.java new file mode 100644 index 000000000..d717d8166 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalEventClaimStrategy.java @@ -0,0 +1,119 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier; + +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.EventClaimStrategy; +import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; +import edu.umd.cs.findbugs.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.function.Consumer; + +/** + * Local (standalone) implementation of EventClaimStrategy. + * Always succeeds since there is no coordination needed in single-instance mode. + * Executes the claimed action immediately. + * + *

This is the fallback implementation used when no higher-priority coordination + * mode (like Hazelcast) is available. In standalone Jenkins deployments, there's + * only one instance, so it always processes all events without coordination.

+ * + * @see com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.LocalCoordinationProvider + * @see EventClaimStrategy + */ +public class LocalEventClaimStrategy extends EventClaimStrategy { + + private static final Logger logger = LoggerFactory.getLogger(LocalEventClaimStrategy.class); + + /** + * Claims the event and executes the action immediately. + * Always succeeds in local mode since there's no contention. + * + * @param event the Gerrit event to claim + * @param claimed action to execute (always runs in local mode) + * @return ClaimResult indicating success or error + */ + @Override + @NonNull + public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runnable claimed) { + // Local mode: always claim and execute immediately + try { + claimed.run(); + return new SuccessfulClaim(); + } catch (Exception e) { + logger.error("Error processing event in local mode", e); + return new FailedClaim(e); + } + } + + /** + * Claim result for successful claim (local mode always succeeds). + */ + private static class SuccessfulClaim implements ClaimResult { + @Override + @NonNull + public ClaimResult notClaimed(@NonNull Runnable notClaimed) { + // Never called - local mode always claims + return this; + } + + @Override + @NonNull + public ClaimResult onError(@NonNull Consumer onError) { + // Never called - no error occurred + return this; + } + } + + /** + * Claim result for failed claim (exception during processing). + */ + private static class FailedClaim implements ClaimResult { + private final Exception exception; + + /** + * Constructor. + * @param exception the exception that occurred + */ + FailedClaim(Exception exception) { + this.exception = exception; + } + + @Override + @NonNull + public ClaimResult notClaimed(@NonNull Runnable notClaimed) { + // Never called - local mode always claims (even if it fails during execution) + return this; + } + + @Override + @NonNull + public ClaimResult onError(@NonNull Consumer onError) { + // Execute error handler + onError.accept(exception); + return this; + } + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java index 893aa9c1d..fb23bfab8 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java @@ -63,6 +63,7 @@ * @see com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.LocalCoordinationProvider * @see BuildMemoryStorage * @see NotificationClaimStrategy + * @see EventClaimStrategy * @see com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.CoordinationModeFactory */ public abstract class CoordinationModeProvider implements ExtensionPoint { @@ -118,4 +119,23 @@ public abstract class CoordinationModeProvider implements ExtensionPoint { * @return a new NotificationClaimStrategy instance (non-null) */ public abstract NotificationClaimStrategy createClaimStrategy(); + + /** + * Creates a new EventClaimStrategy instance for this mode. + * + *

Called once during factory initialization after this provider is selected + * as the highest-priority available provider.

+ * + *

The EventClaimStrategy prevents duplicate build processing when multiple Jenkins + * instances receive the same Gerrit event in HA/HS deployments. In local mode, this + * is a NO-OP (always claims). In distributed mode (e.g., Hazelcast), this uses + * distributed coordination to ensure only one instance processes each event.

+ * + *

Thread Safety: This method may be called from multiple threads during + * factory initialization (double-checked locking). Implementations should be stateless + * or properly synchronized.

+ * + * @return a new EventClaimStrategy instance (non-null) + */ + public abstract EventClaimStrategy createEventClaimStrategy(); } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java new file mode 100644 index 000000000..8e3eb14e1 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java @@ -0,0 +1,136 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.spi; + +import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.util.function.Consumer; + +/** + * Abstract base class for event claiming strategies in different deployment modes. + * Prevents duplicate build processing when multiple Jenkins instances receive + * the same Gerrit event in HA/HS deployments. + * + *

This abstract class enables switching between local (standalone) and coordination modes:

+ *
    + *
  • Local mode: Always claims events - no coordination needed
  • + *
  • Hazelcast mode: Uses distributed coordination to ensure only one replica processes each event
  • + *
  • Future modes: Redis, JDBC, etc.
  • + *
+ * + *

Fluent API Pattern:

+ *

Uses fluent API pattern similar to Jenkins Queue and ACL for automatic resource management:

+ *
+ * claimStrategy.withClaim(event, () -> {
+ *     processEvent(event);
+ * })
+ * .notClaimed(() -> {
+ *     logger.debug("Event already claimed, skipping");
+ * })
+ * .onError((ex) -> {
+ *     logger.error("Error processing event", ex);
+ * });
+ * 
+ * + *

Benefits:

+ *
    + *
  • Automatic claim lifecycle management (no manual release needed)
  • + *
  • No risk of forgetting to release claim in finally blocks
  • + *
  • Cleaner integration code
  • + *
  • Built-in error handling
  • + *
  • Follows Jenkins patterns (Queue, ACL)
  • + *
+ * + *

Implementations are discovered via the Extension Points pattern using + * {@link CoordinationModeProvider}.

+ * + *

Design Note: This is an abstract class (not an interface) to allow + * adding concrete helper methods in the future without breaking existing implementations.

+ * + * @see CoordinationModeProvider + */ +public abstract class EventClaimStrategy { + + /** + * Attempts to claim an event and execute the given action if successful. + * Automatically handles claim lifecycle (acquire + release). + * + *

The claim is automatically released after the action executes (success or failure), + * so implementations do not need manual cleanup code.

+ * + *

Usage Example:

+ *
+     * claimStrategy.withClaim(event, () -> {
+     *     // This code runs only if claim was acquired
+     *     // Claim is automatically released after this block
+     *     processEvent(event);
+     * })
+     * .notClaimed(() -> {
+     *     // Optional: runs if claim was not acquired
+     *     logger.debug("Another instance is processing this event");
+     * })
+     * .onError((ex) -> {
+     *     // Optional: runs if an exception occurs during processing
+     *     logger.error("Failed to process event", ex);
+     * });
+     * 
+ * + * @param event the Gerrit event to claim + * @param claimed action to execute if claim succeeds (runs with claim held, auto-released) + * @return ClaimResult for chaining notClaimed/onError handlers + */ + @NonNull + public abstract ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runnable claimed); + + /** + * Result of a claim attempt, allows chaining handlers for not-claimed and error cases. + * + *

This interface supports a fluent API pattern for handling different outcomes + * of the claim attempt.

+ */ + public interface ClaimResult { + /** + * Handler called if the claim was not acquired (another instance already processing). + * + *

This is optional - if not specified, nothing happens when the claim fails.

+ * + * @param notClaimed action to execute if claim failed + * @return this for chaining + */ + @NonNull + ClaimResult notClaimed(@NonNull Runnable notClaimed); + + /** + * Handler called if an exception occurs during claim processing. + * + *

This is optional - if not specified, exceptions are silently ignored + * (though they may be logged by the implementation).

+ * + * @param onError action to execute on error (receives the exception) + * @return this for chaining + */ + @NonNull + ClaimResult onError(@NonNull Consumer onError); + } +} From 1f2e850f16c1bfd5100712d35cbbb604271383c6 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Wed, 20 May 2026 11:00:45 +0200 Subject: [PATCH 02/87] Changes done, testing --- .../plugins/gerrit/trigger/PluginImpl.java | 67 ++++ .../hazelcast/BuildCancelledProcessor.java | 3 +- .../hazelcast/BuildCompletedProcessor.java | 3 +- .../hazelcast/BuildMemoryKey.java | 3 +- .../hazelcast/BuildStartedProcessor.java | 3 +- .../coordination/hazelcast/EntryData.java | 3 +- .../hazelcast/EntryDataSerializer.java | 3 +- .../coordination/hazelcast/EventClaim.java | 143 ++++++++ .../hazelcast/EventClaimSerializer.java | 79 +++++ .../hazelcast/EventIdentifier.java | 3 +- .../HazelcastBuildMemoryStorage.java | 3 +- .../hazelcast/HazelcastConfig.java | 7 +- .../HazelcastCoordinationProvider.java | 173 ++++++++++ .../HazelcastEventClaimStrategy.java | 309 ++++++++++++++++++ .../hazelcast/HazelcastInstanceProvider.java | 3 +- .../hazelcast/HazelcastManager.java | 3 +- .../HazelcastNotificationClaimStrategy.java | 232 +++++++++++++ .../hazelcast/MemoryImprintData.java | 3 +- .../MemoryImprintDataSerializer.java | 1 - .../hazelcast/SetCustomUrlProcessor.java | 3 +- .../SetUnsuccessfulMessageProcessor.java | 3 +- .../LocalNotificationClaimStrategy.java | 76 ++++- .../trigger/spi/CoordinationModeProvider.java | 24 ++ .../spi/NotificationClaimStrategy.java | 109 ++++-- 24 files changed, 1205 insertions(+), 54 deletions(-) create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaim.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaimSerializer.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java index d03e9e81d..a52dceb16 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java @@ -580,6 +580,12 @@ public void start() { logger.info("Starting Gerrit-Trigger Plugin"); logger.trace("Loading configs"); load(); + + // Initialize Hazelcast early (before any code that might use CoordinationModeFactory) + // This must happen before BuildMemory, EventClaimStrategy, or NotificationClaimStrategy are used + // because HazelcastCoordinationProvider.isAvailable() checks if Hazelcast is initialized + initializeHazelcast(); + GerritSendCommandQueue.initialize(pluginConfig); gerritEventManager = new JenkinsAwareGerritHandler(pluginConfig.getNumberOfReceivingWorkerThreads()); for (GerritServer s : servers) { @@ -588,6 +594,41 @@ public void start() { active = true; } + /** + * Initialize Hazelcast if coordination mode is configured as 'hazelcast'. + *

+ * This is called early in plugin startup, before any code that might use + * CoordinationModeFactory. This ensures HazelcastCoordinationProvider.isAvailable() + * can see that Hazelcast is initialized and ready. + *

+ * Fails gracefully - if initialization fails, plugin continues in local mode. + */ + private void initializeHazelcast() { + String coordinationMode = System.getProperty("gerrit.trigger.coordination.mode", "local"); + + if (!"hazelcast".equalsIgnoreCase(coordinationMode)) { + logger.debug("Coordination mode is '{}', Hazelcast will not be initialized", coordinationMode); + return; + } + + logger.info("Coordination mode is 'hazelcast', initializing Hazelcast..."); + try { + boolean initialized = com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast + .HazelcastManager.initialize(); + if (initialized) { + logger.info("Hazelcast initialized successfully"); + String status = com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast + .HazelcastManager.getStatus(); + logger.info("Hazelcast status: {}", status); + } else { + logger.warn("Hazelcast initialization returned false (mode may be disabled)"); + } + } catch (Exception e) { + logger.error("Failed to initialize Hazelcast. Plugin will continue in local mode.", e); + // Continue plugin startup even if Hazelcast fails - will fall back to local mode + } + } + /** * Forces initialization of the Dispatchers. * @@ -671,6 +712,11 @@ protected static void doXStreamRegistrations() { */ public void stop() { active = false; + + // Shutdown Hazelcast before stopping servers + // This ensures any coordination operations are cleaned up before servers disconnect + shutdownHazelcast(); + for (GerritServer s : servers) { s.stop(); } @@ -683,6 +729,27 @@ public void stop() { servers.clear(); } + /** + * Shutdown Hazelcast if it was initialized. + *

+ * Called during plugin shutdown to clean up Hazelcast resources. + * Fails gracefully - errors are logged but don't prevent plugin shutdown. + */ + private void shutdownHazelcast() { + try { + if (com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast + .HazelcastManager.isInitialized()) { + logger.info("Shutting down Hazelcast..."); + com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast + .HazelcastManager.shutdown(); + logger.info("Hazelcast shutdown complete"); + } + } catch (Exception e) { + logger.error("Error shutting down Hazelcast (non-critical, continuing shutdown)", e); + // Continue with plugin shutdown even if Hazelcast shutdown fails + } + } + /** * Startup hook. */ diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java index 5e9366624..aba35c363 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java @@ -1,7 +1,7 @@ /* * The MIT License * - * Copyright (c) 2010, 2014 Sony Mobile Communications Inc. All rights reserved. + * Copyright 2026 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 @@ -31,7 +31,6 @@ * Hazelcast EntryProcessor for atomically marking a build as cancelled. * Executes on the partition owner to prevent race conditions. * - * @author Robert Sandell <robert.sandell@sonyericsson.com> */ public class BuildCancelledProcessor implements EntryProcessor { private static final long serialVersionUID = 1L; diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCompletedProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCompletedProcessor.java index 137c4ef93..400e3c81a 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCompletedProcessor.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCompletedProcessor.java @@ -1,7 +1,7 @@ /* * The MIT License * - * Copyright (c) 2010, 2014 Sony Mobile Communications Inc. All rights reserved. + * Copyright 2026 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 @@ -32,7 +32,6 @@ * Executes on the partition owner to prevent race conditions when multiple * replicas update the same event simultaneously. * - * @author Robert Sandell <robert.sandell@sonyericsson.com> */ public class BuildCompletedProcessor implements EntryProcessor { private static final long serialVersionUID = 1L; diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildMemoryKey.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildMemoryKey.java index cf734c130..fe9e79632 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildMemoryKey.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildMemoryKey.java @@ -1,7 +1,7 @@ /* * The MIT License * - * + * Copyright 2026 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 @@ -33,7 +33,6 @@ * Uses event ID instead of event object for serialization efficiency. * The event ID is deterministic (same event on different replicas produces same ID). * - * @author CloudBees, Inc. */ public class BuildMemoryKey implements Serializable { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildStartedProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildStartedProcessor.java index a44a66428..23ac4b675 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildStartedProcessor.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildStartedProcessor.java @@ -1,7 +1,7 @@ /* * The MIT License * - * Copyright (c) 2010, 2014 Sony Mobile Communications Inc. All rights reserved. + * Copyright 2026 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 @@ -31,7 +31,6 @@ * Hazelcast EntryProcessor for atomically marking a build as started. * Executes on the partition owner to prevent race conditions. * - * @author Robert Sandell <robert.sandell@sonyericsson.com> */ public class BuildStartedProcessor implements EntryProcessor { private static final long serialVersionUID = 1L; diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java index 231b3d4c0..2b13775b1 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java @@ -1,7 +1,7 @@ /* * The MIT License * - * + * Copyright 2026 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 @@ -31,7 +31,6 @@ * Stores job and build information without Jenkins object references. * Uses Compact Serialization for cross-JVM compatibility. * - * @author CloudBees, Inc. */ public class EntryData { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java index a9ba22216..b5c87dc22 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java @@ -1,7 +1,7 @@ /* * The MIT License * - * + * Copyright 2026 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 @@ -33,7 +33,6 @@ *

* Serializes individual BuildMemory entries with fixed schema. * - * @author CloudBees, Inc. */ public class EntryDataSerializer implements CompactSerializer { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaim.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaim.java new file mode 100644 index 000000000..0c2123e4a --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaim.java @@ -0,0 +1,143 @@ +/* + * The MIT License + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +/** + * Represents a claimed Gerrit event in the distributed cluster. + *

+ * In HA/HS environments with multiple replicas, each Gerrit event + * arrives at all replicas. To prevent duplicate builds, replicas use event claiming: + * the first replica to claim an event processes it, while others skip it. + *

+ * EventClaim objects are stored in a Hazelcast IMap to coordinate claims across replicas. + * Claims automatically expire via TTL to prevent memory leaks. + *

+ * Serialization: Uses Hazelcast Compact Serialization for cross-JVM + * compatibility with sidecar deployment. The sidecar Hazelcast cluster doesn't need + * this class in its classpath. + * + * @author CloudBees, Inc. + */ +public class EventClaim { + + /** + * Unique event identifier (generated by {@link EventIdentifier}). + */ + private final String eventId; + + /** + * Jenkins instance (hostname/pod name) that claimed the event. + */ + private final String claimedBy; + + /** + * Timestamp when event was claimed (milliseconds since epoch). + */ + private final long claimedAt; + + /** + * Event type for logging and debugging (e.g., "patchset-created"). + */ + private final String eventType; + + /** + * Standard constructor. + * + * @param eventId unique event identifier + * @param claimedBy instance that claimed the event + * @param claimedAt timestamp when claimed + * @param eventType Gerrit event type + */ + public EventClaim(String eventId, String claimedBy, long claimedAt, String eventType) { + this.eventId = eventId; + this.claimedBy = claimedBy; + this.claimedAt = claimedAt; + this.eventType = eventType; + } + + /** + * Gets the unique event identifier. + * + * @return event ID + */ + public String getEventId() { + return eventId; + } + + /** + * Gets the instance that claimed the event. + * + * @return claimant instance identifier + */ + public String getClaimedBy() { + return claimedBy; + } + + /** + * Gets the timestamp when the event was claimed. + * + * @return claim timestamp (milliseconds since epoch) + */ + public long getClaimedAt() { + return claimedAt; + } + + /** + * Gets the Gerrit event type. + * + * @return event type + */ + public String getEventType() { + return eventType; + } + + @Override + public String toString() { + return String.format("EventClaim[id=%s, by=%s, at=%d, type=%s]", + eventId, claimedBy, claimedAt, eventType); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + EventClaim that = (EventClaim)o; + if (eventId != null) { + return eventId.equals(that.eventId); + } else { + return that.eventId == null; + } + } + + @Override + public int hashCode() { + if (eventId != null) { + return eventId.hashCode(); + } else { + return 0; + } + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaimSerializer.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaimSerializer.java new file mode 100644 index 000000000..e9a7e0558 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaimSerializer.java @@ -0,0 +1,79 @@ +/* + * The MIT License + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.nio.serialization.compact.CompactReader; +import com.hazelcast.nio.serialization.compact.CompactSerializer; +import com.hazelcast.nio.serialization.compact.CompactWriter; +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * Hazelcast Compact Serializer for {@link EventClaim}. + *

+ * Compact Serialization is schema-based and doesn't require class definitions + * on the Hazelcast server (sidecar container). This enables cross-JVM serialization + * without classloading issues. + *

+ * The serializer writes a schema with field names and types, which the sidecar + * Hazelcast can process without needing the EventClaim class. + * + * @author CloudBees, Inc. + */ +public class EventClaimSerializer implements CompactSerializer { + + /** + * Type name for schema registration. + * Must be unique across all compact serialized types. + */ + private static final String TYPE_NAME = "EventClaim"; + + @Override + @NonNull + public EventClaim read(@NonNull CompactReader reader) { + String eventId = reader.readString("eventId"); + String claimedBy = reader.readString("claimedBy"); + long claimedAt = reader.readInt64("claimedAt"); + String eventType = reader.readString("eventType"); + + return new EventClaim(eventId, claimedBy, claimedAt, eventType); + } + + @Override + public void write(@NonNull CompactWriter writer, @NonNull EventClaim claim) { + writer.writeString("eventId", claim.getEventId()); + writer.writeString("claimedBy", claim.getClaimedBy()); + writer.writeInt64("claimedAt", claim.getClaimedAt()); + writer.writeString("eventType", claim.getEventType()); + } + + @Override + @NonNull + public String getTypeName() { + return TYPE_NAME; + } + + @Override + @NonNull + public Class getCompactClass() { + return EventClaim.class; + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java index 2c8528a1f..20742bc33 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java @@ -1,6 +1,8 @@ /* * The MIT License * + * Copyright 2026 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 @@ -45,7 +47,6 @@ * Important: Uses {@code eventCreatedOn} (server timestamp) rather than {@code receivedOn} * (replica timestamp) to ensure identical event IDs across all replicas receiving the same event. * - * @author CloudBees, Inc. */ public final class EventIdentifier { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index b6b8af4fa..656f64558 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -1,6 +1,8 @@ /* * The MIT License * + * Copyright 2026 CloudBees, Inc. + * * Copyright 2010 Sony Ericsson Mobile Communications. All rights reserved. * Copyright 2012 Sony Mobile Communications AB. All rights reserved. * @@ -59,7 +61,6 @@ * * * @see HazelcastCoordinationProvider - * @author Robert Sandell <robert.sandell@sonyericsson.com> */ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java index cd994e0e5..2b83d7387 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java @@ -1,6 +1,8 @@ /* * The MIT License * + * Copyright 2026 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 @@ -32,7 +34,6 @@ * Configuration builder for Hazelcast cluster. * Creates appropriate configuration based on deployment environment (Kubernetes, TCP/IP, etc.). * - * @author CloudBees, Inc. */ public final class HazelcastConfig { @@ -144,12 +145,12 @@ public static Config createConfig() { // Register Compact Serializers for event claiming and build memory // This enables cross-JVM serialization compatibility with sidecar deployment - // Note: EventClaimSerializer will be added in Phase 6 when EventClaim is ported config.getSerializationConfig() .getCompactSerializationConfig() + .addSerializer(new EventClaimSerializer()) .addSerializer(new EntryDataSerializer()) .addSerializer(new MemoryImprintDataSerializer()); - logger.debug("Registered Compact Serializers for EntryData and MemoryImprintData"); + logger.debug("Registered Compact Serializers for EventClaim, EntryData, and MemoryImprintData"); logger.info("Hazelcast configuration created for cluster: {}", clusterName); diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java new file mode 100644 index 000000000..9011ef167 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java @@ -0,0 +1,173 @@ +/* + * The MIT License + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.BuildMemoryStorage; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.EventClaimStrategy; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.NotificationClaimStrategy; +import hudson.Extension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Coordination provider for Hazelcast distributed mode. + *

+ * This provider is automatically discovered via Jenkins Extension Points mechanism. + * It has higher priority than LocalCoordinationProvider (100 vs -1000), so it will be + * selected when available. + *

+ * Availability Criteria: + *

    + *
  • Coordination mode set to 'hazelcast' via system property: + * {@code -Dgerrit.trigger.coordination.mode=hazelcast}
  • + *
  • Hazelcast instance is initialized and running
  • + *
+ *

+ * When selected, provides: + *

    + *
  • {@link HazelcastBuildMemoryStorage} - Distributed build tracking across replicas
  • + *
  • {@link HazelcastNotificationClaimStrategy} - Notification coordination to prevent duplicates
  • + *
  • {@link HazelcastEventClaimStrategy} - Event processing coordination to prevent duplicates
  • + *
+ *

+ * Architecture Note: This single class replaces ALL the + * {@code if (ClusterModeProvider.isClusterModeEnabled())} checks throughout the codebase! + * All three coordination concerns (build state storage, notification rights, event processing rights) + * now use the same Extension Points pattern consistently. + * + * @see CoordinationModeFactory + * @see com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.LocalCoordinationProvider (fallback) + * @author CloudBees, Inc. + */ +// CHECKSTYLE:OFF MagicNumber - Ordinal must be literal in annotation, 100 ensures higher priority than fallback +@Extension(ordinal = HazelcastCoordinationProvider.HAZELCAST_PRIORITY) +// CHECKSTYLE:ON MagicNumber +public class HazelcastCoordinationProvider extends CoordinationModeProvider { + + private static final Logger logger = LoggerFactory.getLogger(HazelcastCoordinationProvider.class); + + /** + * Extension ordinal priority for Hazelcast coordination provider. + * Higher value than LocalCoordinationProvider (-1000) ensures this is selected first when available. + */ + static final int HAZELCAST_PRIORITY = 100; + + /** + * The mode name that enables this provider. + */ + private static final String HAZELCAST_MODE = "hazelcast"; + + /** + * Checks if this provider is available. + *

+ * Returns true only if: + *

    + *
  • Coordination mode is configured as 'hazelcast' (via system property)
  • + *
  • Hazelcast instance is initialized and running
  • + *
+ *

+ * Uses the {@link CoordinationModeProvider#getConfiguredMode()} helper method to check + * the coordination mode. This is future-proof - when we add UI configuration for coordination + * modes, only that one helper method needs to be updated. + * + * @return true if Hazelcast coordination mode is available, false otherwise + */ + @Override + public boolean isAvailable() { + // Check coordination mode using helper method (future-proof for UI config) + String configuredMode = getConfiguredMode(); + if (!HAZELCAST_MODE.equalsIgnoreCase(configuredMode)) { + logger.trace("Coordination mode is '{}', not '{}'", configuredMode, HAZELCAST_MODE); + return false; + } + + // Check Hazelcast availability + if (!HazelcastInstanceProvider.isInitialized()) { + logger.warn("Coordination mode is '{}' but Hazelcast not initialized. " + + "Hazelcast must be initialized before coordination provider discovery. " + + "Falling back to local mode.", HAZELCAST_MODE); + return false; + } + + logger.info("Hazelcast coordination mode active"); + return true; + } + + /** + * Returns the human-readable name of this coordination mode. + * + * @return "Hazelcast (Distributed)" + */ + @Override + public String getModeName() { + return "Hazelcast (Distributed)"; + } + + /** + * Creates Hazelcast-backed build memory storage. + *

+ * Uses distributed IMap to share build tracking state across all Jenkins replicas. + * All build lifecycle events (triggered, started, completed) are stored in Hazelcast, + * allowing any replica to see what builds other replicas are processing. + * + * @return HazelcastBuildMemoryStorage instance + */ + @Override + public BuildMemoryStorage createStorage() { + logger.info("Creating HazelcastBuildMemoryStorage"); + return new HazelcastBuildMemoryStorage(); + } + + /** + * Creates Hazelcast notification claim strategy. + *

+ * Uses distributed atomic operations to ensure only one replica sends feedback + * to Gerrit for each event. Prevents duplicate comments/votes on Gerrit reviews. + * + * @return HazelcastNotificationClaimStrategy instance + */ + @Override + public NotificationClaimStrategy createClaimStrategy() { + logger.info("Creating HazelcastNotificationClaimStrategy"); + return new HazelcastNotificationClaimStrategy(); + } + + /** + * Creates Hazelcast event claim strategy. + *

+ * Uses distributed IMap with atomic {@code putIfAbsent} to ensure only one replica + * processes each Gerrit event. The first replica to claim an event processes it, + * while other replicas skip it. This prevents duplicate builds in HA/HS deployments. + *

+ * Replica-level claiming: Once a replica claims an event, ALL jobs + * on that replica can process it. This allows multiple jobs on the same replica to be + * triggered by the same event while preventing duplicate processing across replicas. + * + * @return HazelcastEventClaimStrategy instance + */ + @Override + public EventClaimStrategy createEventClaimStrategy() { + logger.info("Creating HazelcastEventClaimStrategy"); + return new HazelcastEventClaimStrategy(); + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java new file mode 100644 index 000000000..c01bc1f29 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java @@ -0,0 +1,309 @@ +/* + * The MIT License + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.map.IMap; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.EventClaimStrategy; +import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; +import edu.umd.cs.findbugs.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.InetAddress; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * Hazelcast-backed implementation of EventClaimStrategy for HA/HS deployments. + *

+ * In CloudBees HA/HS environments with multiple replicas, each Gerrit event + * arrives at all replicas via SSH event stream. To prevent duplicate builds, + * replicas use distributed event claiming: + *

    + *
  • First replica to claim an event processes it
  • + *
  • Other replicas skip the event (already claimed)
  • + *
+ *

+ * Claims are stored in a Hazelcast IMap with atomic {@code putIfAbsent} operations + * to prevent race conditions. Claims automatically expire via TTL to prevent memory leaks. + *

+ * Replica-level claiming: Once a replica claims an event, ALL jobs + * on that replica can process it. This allows multiple jobs on the same replica to be + * triggered by the same event while preventing duplicate processing across replicas. + *

+ * Fail-open behavior: If Hazelcast is unavailable, this strategy + * allows event processing to continue (better to risk duplicate builds than drop events). + * + * @author CloudBees, Inc. + */ +public class HazelcastEventClaimStrategy extends EventClaimStrategy { + + private static final Logger logger = LoggerFactory.getLogger(HazelcastEventClaimStrategy.class); + + /** + * Hazelcast map name for event claims. + */ + private static final String CLAIMS_MAP_NAME = "gerrit-trigger-event-claims"; + + /** + * Default TTL for event claims in seconds (5 minutes). + * Claims expire automatically to prevent memory leaks. + */ + private static final long DEFAULT_CLAIM_TTL_SECONDS = 300; + + /** + * System property to override claim TTL. + * Example: -Dgerrit.trigger.coordination.hazelcast.claim.ttl.seconds=600 + */ + private static final String CLAIM_TTL_PROPERTY = "gerrit.trigger.coordination.hazelcast.claim.ttl.seconds"; + + /** + * Cached instance identifier (hostname or pod name). + */ + private static volatile String instanceId = null; + + @Override + @NonNull + public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runnable claimed) { + // Get Hazelcast instance + HazelcastInstance hazelcast = HazelcastInstanceProvider.getInstance(); + if (hazelcast == null) { + logger.error("Hazelcast not available, cannot claim event. Allowing event processing (fail-open)."); + // Fail-open: execute the action even without claiming + try { + claimed.run(); + return new SuccessfulClaim(); + } catch (Exception e) { + return new FailedClaim(e); + } + } + + // Generate event ID + String eventId = EventIdentifier.generateEventId(event); + String thisInstanceId = getInstanceId(); + + try { + // Get claims map + IMap claimsMap = hazelcast.getMap(CLAIMS_MAP_NAME); + + // Check if event is already claimed + EventClaim existingClaim = claimsMap.get(eventId); + + if (existingClaim != null) { + // Event already claimed - check who claimed it + if (existingClaim.getClaimedBy().equals(thisInstanceId)) { + // Claimed by THIS replica (another job already processed it) + // Allow this job to also process the event + logger.trace("Event already claimed by this replica, allowing: {} (job processing)", + eventId); + claimed.run(); + return new SuccessfulClaim(); + } else { + // Claimed by ANOTHER replica - skip processing + logger.debug("Event already claimed by {}: {} (type: {})", + existingClaim.getClaimedBy(), eventId, event.getEventType().getTypeValue()); + return new NotClaimedResult(); + } + } + + // Event not yet claimed - attempt to claim it + EventClaim claim = new EventClaim( + eventId, + thisInstanceId, + System.currentTimeMillis(), + event.getEventType().getTypeValue() + ); + + // Attempt atomic claim with TTL + EventClaim previousClaim = claimsMap.putIfAbsent( + eventId, + claim, + getClaimTtlSeconds(), + TimeUnit.SECONDS + ); + + if (previousClaim == null) { + // Successfully claimed by this replica + logger.debug("Successfully claimed event: {} (type: {})", + eventId, event.getEventType().getTypeValue()); + claimed.run(); + return new SuccessfulClaim(); + } else { + // Race condition: another replica claimed it between our get() and putIfAbsent() + // Check if it was claimed by this replica or another + if (previousClaim.getClaimedBy().equals(thisInstanceId)) { + // Claimed by THIS replica (race between jobs on same replica) + logger.trace("Event claimed by this replica during race condition: {}", eventId); + claimed.run(); + return new SuccessfulClaim(); + } else { + // Claimed by ANOTHER replica + logger.debug("Event claimed by {} during race condition: {} (type: {})", + previousClaim.getClaimedBy(), eventId, event.getEventType().getTypeValue()); + return new NotClaimedResult(); + } + } + } catch (Exception e) { + // Hazelcast operation failed + logger.error("Failed to claim event, allowing processing to continue (fail-open): " + eventId, e); + // Fail-open: execute the action even on error + try { + claimed.run(); + } catch (Exception innerException) { + logger.error("Error executing claimed action after claim failure", innerException); + return new FailedClaim(innerException); + } + return new SuccessfulClaim(); + } + } + + /** + * Gets the current instance identifier. + *

+ * Uses hostname or pod name to identify this Jenkins instance. + * Cached after first retrieval for performance. + * + * @return instance ID (hostname or pod name) + */ + private static String getInstanceId() { + if (instanceId == null) { + synchronized (HazelcastEventClaimStrategy.class) { + if (instanceId == null) { + try { + instanceId = System.getenv("HOSTNAME"); + if (instanceId == null || "".equals(instanceId.trim())) { + instanceId = InetAddress.getLocalHost().getHostName(); + } + } catch (Exception e) { + logger.warn("Could not determine hostname, using fallback", e); + instanceId = "unknown-" + System.currentTimeMillis(); + } + } + } + } + return instanceId; + } + + /** + * Gets the configured claim TTL in seconds. + *

+ * Can be overridden via system property {@link #CLAIM_TTL_PROPERTY}. + * Default is {@link #DEFAULT_CLAIM_TTL_SECONDS} (5 minutes). + * + * @return TTL in seconds + */ + private static long getClaimTtlSeconds() { + String ttlProperty = System.getProperty(CLAIM_TTL_PROPERTY); + if (ttlProperty != null) { + try { + long ttl = Long.parseLong(ttlProperty); + if (ttl > 0) { + return ttl; + } else { + logger.warn("Invalid claim TTL property (must be > 0): {}, using default", ttlProperty); + } + } catch (NumberFormatException e) { + logger.warn("Invalid claim TTL property (not a number): {}, using default", ttlProperty); + } + } + return DEFAULT_CLAIM_TTL_SECONDS; + } + + /** + * Successful claim result - the action was executed. + */ + private static class SuccessfulClaim implements ClaimResult { + @Override + @NonNull + public ClaimResult notClaimed(@NonNull Runnable notClaimed) { + // Claim was successful, don't run notClaimed handler + return this; + } + + @Override + @NonNull + public ClaimResult onError(@NonNull Consumer onError) { + // No error occurred + return this; + } + } + + /** + * Not claimed result - another instance already claimed the event. + */ + private static class NotClaimedResult implements ClaimResult { + @Override + @NonNull + public ClaimResult notClaimed(@NonNull Runnable notClaimed) { + // Run the notClaimed handler + try { + notClaimed.run(); + } catch (Exception e) { + logger.error("Error in notClaimed handler", e); + } + return this; + } + + @Override + @NonNull + public ClaimResult onError(@NonNull Consumer onError) { + // No error occurred (just not claimed) + return this; + } + } + + /** + * Failed claim result - an error occurred during processing. + */ + private static class FailedClaim implements ClaimResult { + private final Exception exception; + + /** + * Constructor. + * + * @param exception the exception that occurred + */ + FailedClaim(Exception exception) { + this.exception = exception; + } + + @Override + @NonNull + public ClaimResult notClaimed(@NonNull Runnable notClaimed) { + // Don't run notClaimed - this was an error, not a "not claimed" situation + return this; + } + + @Override + @NonNull + public ClaimResult onError(@NonNull Consumer onError) { + // Run the error handler + try { + onError.accept(exception); + } catch (Exception e) { + logger.error("Error in error handler", e); + } + return this; + } + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastInstanceProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastInstanceProvider.java index 0e20b40d0..c13acedda 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastInstanceProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastInstanceProvider.java @@ -1,6 +1,8 @@ /* * The MIT License * + * Copyright 2026 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 @@ -33,7 +35,6 @@ * Provides thread-safe access to the Hazelcast embedded member instance. * The instance is set by {@link HazelcastManager} during initialization. * - * @author CloudBees, Inc. */ public final class HazelcastInstanceProvider { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java index 7c8ccc164..fe8d29897 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java @@ -1,6 +1,8 @@ /* * The MIT License * + * Copyright 2026 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 @@ -33,7 +35,6 @@ * Whether to initialize is determined by {@link HazelcastCoordinationProvider#isAvailable()}, * not by this class. * - * @author CloudBees, Inc. */ public final class HazelcastManager { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java new file mode 100644 index 000000000..28955bd49 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java @@ -0,0 +1,232 @@ +/* + * The MIT License + * + * 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.map.IMap; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.NotificationClaimStrategy; +import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; +import edu.umd.cs.findbugs.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * Hazelcast-backed implementation of NotificationClaimStrategy for HA/HS deployments. + *

+ * In CloudBees HA/HS environments with multiple replicas, each replica tracks build + * completions independently. To prevent duplicate notifications to Gerrit, + * replicas use distributed notification claiming: + *

    + *
  • First replica to claim notification right sends feedback to Gerrit
  • + *
  • Other replicas skip notification (already sent)
  • + *
+ *

+ * Claims are stored in a Hazelcast IMap with atomic {@code putIfAbsent} operations + * to prevent race conditions. Claims automatically expire via TTL to prevent memory leaks. + *

+ * Fail-open behavior: If Hazelcast is unavailable, this strategy + * allows notification sending to continue (better to risk duplicate notifications than + * lose feedback entirely). + * + * @author CloudBees, Inc. + */ +public class HazelcastNotificationClaimStrategy extends NotificationClaimStrategy { + + private static final Logger logger = LoggerFactory.getLogger(HazelcastNotificationClaimStrategy.class); + + /** + * Hazelcast map name for notification claim flags. + */ + private static final String NOTIFICATION_FLAGS_MAP = "gerrit-trigger-notification-flags"; + + /** + * Default notification claim TTL in minutes (10 minutes). + * Claims expire automatically to prevent memory leaks. + */ + private static final int DEFAULT_NOTIFICATION_CLAIM_TTL_MINUTES = 10; + + /** + * System property to override notification claim TTL. + * Example: -Dgerrit.trigger.coordination.hazelcast.notification.ttl.minutes=20 + */ + private static final String NOTIFICATION_TTL_PROPERTY = + "gerrit.trigger.coordination.hazelcast.notification.ttl.minutes"; + + @Override + @NonNull + public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runnable claimed) { + // Get Hazelcast instance + HazelcastInstance hz = HazelcastInstanceProvider.getInstance(); + if (hz == null) { + logger.warn("Hazelcast not available for notification claim, proceeding with local mode (fail-open)"); + // Fail-open: execute the notification action even without claiming + try { + claimed.run(); + return new SuccessfulClaim(); + } catch (Exception e) { + return new FailedClaim(e); + } + } + + try { + IMap notificationFlags = hz.getMap(NOTIFICATION_FLAGS_MAP); + String eventId = EventIdentifier.generateEventId(event); + String flagKey = "notified-" + eventId; + + // Atomic operation: set flag if not already set + Boolean previousValue = notificationFlags.putIfAbsent( + flagKey, + Boolean.TRUE, + getNotificationTtlMinutes(), + TimeUnit.MINUTES + ); + + if (previousValue == null) { + // Successfully claimed notification right + logger.debug("Successfully claimed notification right for event: {}", eventId); + claimed.run(); + return new SuccessfulClaim(); + } else { + // Another replica already claimed notification + logger.debug("Another replica already claimed notification for event: {}", eventId); + return new NotClaimedResult(); + } + } catch (Exception e) { + // Hazelcast operation failed + logger.error("Error claiming notification right, proceeding with send to avoid notification loss", e); + // Fail-open: execute the notification action even on error + try { + claimed.run(); + } catch (Exception innerException) { + logger.error("Error executing notification action after claim failure", innerException); + return new FailedClaim(innerException); + } + return new SuccessfulClaim(); + } + } + + /** + * Gets the configured notification claim TTL in minutes. + *

+ * Can be overridden via system property {@link #NOTIFICATION_TTL_PROPERTY}. + * Default is {@link #DEFAULT_NOTIFICATION_CLAIM_TTL_MINUTES} (10 minutes). + * + * @return TTL in minutes + */ + private static int getNotificationTtlMinutes() { + String ttlProperty = System.getProperty(NOTIFICATION_TTL_PROPERTY); + if (ttlProperty != null) { + try { + int ttl = Integer.parseInt(ttlProperty); + if (ttl > 0) { + return ttl; + } else { + logger.warn("Invalid notification TTL property (must be > 0): {}, using default", ttlProperty); + } + } catch (NumberFormatException e) { + logger.warn("Invalid notification TTL property (not a number): {}, using default", ttlProperty); + } + } + return DEFAULT_NOTIFICATION_CLAIM_TTL_MINUTES; + } + + /** + * Successful claim result - the action was executed. + */ + private static class SuccessfulClaim implements ClaimResult { + @Override + @NonNull + public ClaimResult notClaimed(@NonNull Runnable notClaimed) { + // Claim was successful, don't run notClaimed handler + return this; + } + + @Override + @NonNull + public ClaimResult onError(@NonNull Consumer onError) { + // No error occurred + return this; + } + } + + /** + * Not claimed result - another instance already claimed the notification. + */ + private static class NotClaimedResult implements ClaimResult { + @Override + @NonNull + public ClaimResult notClaimed(@NonNull Runnable notClaimed) { + // Run the notClaimed handler + try { + notClaimed.run(); + } catch (Exception e) { + logger.error("Error in notClaimed handler", e); + } + return this; + } + + @Override + @NonNull + public ClaimResult onError(@NonNull Consumer onError) { + // No error occurred (just not claimed) + return this; + } + } + + /** + * Failed claim result - an error occurred during processing. + */ + private static class FailedClaim implements ClaimResult { + private final Exception exception; + + /** + * Constructor. + * + * @param exception the exception that occurred + */ + FailedClaim(Exception exception) { + this.exception = exception; + } + + @Override + @NonNull + public ClaimResult notClaimed(@NonNull Runnable notClaimed) { + // Don't run notClaimed - this was an error, not a "not claimed" situation + return this; + } + + @Override + @NonNull + public ClaimResult onError(@NonNull Consumer onError) { + // Run the error handler + try { + onError.accept(exception); + } catch (Exception e) { + logger.error("Error in error handler", e); + } + return this; + } + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintData.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintData.java index 8e1e2036c..baf794d06 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintData.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintData.java @@ -1,7 +1,7 @@ /* * The MIT License * - * + * Copyright 2026 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 @@ -32,7 +32,6 @@ * Contains simplified Entry data without complex object references. * Uses Compact Serialization for cross-JVM compatibility in sidecar deployments. * - * @author CloudBees, Inc. */ public class MemoryImprintData { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintDataSerializer.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintDataSerializer.java index 2af8e5a0b..41ea9ad38 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintDataSerializer.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintDataSerializer.java @@ -38,7 +38,6 @@ * on the Hazelcast server (sidecar container). This enables cross-JVM serialization * without classloading issues. * - * @author CloudBees, Inc. */ public class MemoryImprintDataSerializer implements CompactSerializer { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCustomUrlProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCustomUrlProcessor.java index 1f02f63f0..79eca757e 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCustomUrlProcessor.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCustomUrlProcessor.java @@ -1,7 +1,7 @@ /* * The MIT License * - * Copyright (c) 2010, 2014 Sony Mobile Communications Inc. All rights reserved. + * Copyright 2026 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 @@ -31,7 +31,6 @@ * Hazelcast EntryProcessor for atomically setting a custom URL for a build. * Executes on the partition owner to prevent race conditions. * - * @author Robert Sandell <robert.sandell@sonyericsson.com> */ public class SetCustomUrlProcessor implements EntryProcessor { private static final long serialVersionUID = 1L; diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetUnsuccessfulMessageProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetUnsuccessfulMessageProcessor.java index 63995890f..a15becdc0 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetUnsuccessfulMessageProcessor.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetUnsuccessfulMessageProcessor.java @@ -1,7 +1,7 @@ /* * The MIT License * - * Copyright (c) 2010, 2014 Sony Mobile Communications Inc. All rights reserved. + * Copyright 2026 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 @@ -31,7 +31,6 @@ * Hazelcast EntryProcessor for atomically setting an unsuccessful message for a build. * Executes on the partition owner to prevent race conditions. * - * @author Robert Sandell <robert.sandell@sonyericsson.com> */ public class SetUnsuccessfulMessageProcessor implements EntryProcessor { private static final long serialVersionUID = 1L; diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalNotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalNotificationClaimStrategy.java index 6f630e435..802949388 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalNotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalNotificationClaimStrategy.java @@ -26,10 +26,14 @@ import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.NotificationClaimStrategy; import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; import edu.umd.cs.findbugs.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.function.Consumer; /** * Local (non-cluster) implementation of notification claiming. - * Always returns true since there's no need for coordination in standalone mode. + * Always executes the notification action since there's no need for coordination in standalone mode. * *

This is the default/fallback implementation used when cluster mode is not enabled. * In standalone Jenkins deployments, there's only one instance, so it always has the @@ -39,14 +43,72 @@ */ public class LocalNotificationClaimStrategy extends NotificationClaimStrategy { + private static final Logger logger = LoggerFactory.getLogger(LocalNotificationClaimStrategy.class); + @Override - public boolean tryClaimNotificationRight(@NonNull GerritTriggeredEvent event) { - // In local mode, always send notifications - no coordination needed - return true; + @NonNull + public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runnable claimed) { + // In local mode, always allow notification - no coordination needed + try { + claimed.run(); + return new SuccessfulClaim(); + } catch (Exception e) { + logger.error("Error executing notification action", e); + return new FailedClaim(e); + } } - @Override - public void releaseNotificationRight(@NonNull GerritTriggeredEvent event) { - // No-op in local mode - nothing to release + /** + * Successful claim result - the action was executed. + */ + private static class SuccessfulClaim implements ClaimResult { + @Override + @NonNull + public ClaimResult notClaimed(@NonNull Runnable notClaimed) { + // Claim was successful, don't run notClaimed handler + return this; + } + + @Override + @NonNull + public ClaimResult onError(@NonNull Consumer onError) { + // No error occurred + return this; + } + } + + /** + * Failed claim result - an error occurred during processing. + */ + private static class FailedClaim implements ClaimResult { + private final Exception exception; + + /** + * Constructor. + * + * @param exception the exception that occurred + */ + FailedClaim(Exception exception) { + this.exception = exception; + } + + @Override + @NonNull + public ClaimResult notClaimed(@NonNull Runnable notClaimed) { + // Don't run notClaimed - this was an error, not a "not claimed" situation + return this; + } + + @Override + @NonNull + public ClaimResult onError(@NonNull Consumer onError) { + // Run the error handler + try { + onError.accept(exception); + } catch (Exception e) { + logger.error("Error in error handler", e); + } + return this; + } } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java index fb23bfab8..bdcf1a506 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java @@ -68,6 +68,30 @@ */ public abstract class CoordinationModeProvider implements ExtensionPoint { + /** + * System property to specify coordination mode. + */ + private static final String COORDINATION_MODE_PROPERTY = "gerrit.trigger.coordination.mode"; + + /** + * Gets the configured coordination mode. + *

+ * Helper method for providers: Centralizes the logic for determining + * which coordination mode is configured. This allows future evolution from system + * properties to UI configuration without changing provider implementations. + *

+ * Today: Reads from system property + * {@code gerrit.trigger.coordination.mode} (default: "local") + *

+ * Future: Can check UI configuration when providers add config pages + * (e.g., Redis/JDBC providers with connection settings in Jenkins UI) + * + * @return the configured mode name (e.g., "local", "hazelcast", "redis", "jdbc") + */ + public static String getConfiguredMode() { + return System.getProperty(COORDINATION_MODE_PROPERTY, "local"); + } + /** * Checks if this mode provider can create implementations in the current environment. * diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java index 0888fc613..02bf8f1d0 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java @@ -25,45 +25,112 @@ import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; import edu.umd.cs.findbugs.annotations.NonNull; +import java.util.function.Consumer; /** * Abstract base class for notification claiming strategies in different deployment modes. - * Subclasses handle the coordination of which Jenkins instance should send - * notifications to Gerrit in HA/HS deployments. + * Prevents duplicate notification sending when multiple Jenkins instances need to send + * feedback to Gerrit in HA/HS deployments. * - *

This abstract class enables switching between local (standalone) and cluster modes:

+ *

This abstract class enables switching between local (standalone) and coordination modes:

*
    *
  • Local mode: Always claims notification rights - no coordination needed
  • - *
  • Cluster mode: Uses distributed coordination (e.g., Hazelcast) to ensure - * only one replica sends feedback to Gerrit per event
  • + *
  • Hazelcast mode: Uses distributed coordination to ensure only one replica sends feedback
  • + *
  • Future modes: Redis, JDBC, etc.
  • + *
+ * + *

Fluent API Pattern:

+ *

Uses fluent API pattern similar to Jenkins Queue and ACL for automatic resource management:

+ *
+ * claimStrategy.withClaim(event, () -> {
+ *     sendNotificationToGerrit(event, buildResult);
+ * })
+ * .notClaimed(() -> {
+ *     logger.debug("Another replica sent notification, skipping");
+ * })
+ * .onError((ex) -> {
+ *     logger.error("Failed to send notification", ex);
+ * });
+ * 
+ * + *

Benefits:

+ *
    + *
  • Automatic claim lifecycle management (no manual release needed)
  • + *
  • No risk of forgetting to release claim in finally blocks
  • + *
  • Cleaner integration code
  • + *
  • Built-in error handling
  • + *
  • Follows Jenkins patterns (Queue, ACL)
  • *
* *

Implementations are discovered via the Extension Points pattern using - * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider}.

- *

- * Design Note: This is an abstract class (not an interface) to allow - * adding concrete helper methods in the future without breaking existing implementations. - * This follows Jenkins plugin development best practices. + * {@link CoordinationModeProvider}.

* - * @see com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider + *

Design Note: This is an abstract class (not an interface) to allow + * adding concrete helper methods in the future without breaking existing implementations.

+ * + * @see CoordinationModeProvider */ public abstract class NotificationClaimStrategy { /** - * Attempts to claim the right to send notification for an event. - * In local mode, always returns true. In cluster mode, uses distributed - * coordination to ensure only one replica sends the notification. + * Attempts to claim the right to send notification and execute the given action if successful. + * Automatically handles claim lifecycle (acquire + release). + * + *

The claim is automatically released after the action executes (success or failure), + * so implementations do not need manual cleanup code.

* - * @param event the Gerrit event - * @return true if this instance should send the notification, false otherwise + *

Usage Example:

+ *
+     * claimStrategy.withClaim(event, () -> {
+     *     // This code runs only if claim was acquired
+     *     // Claim is automatically released after this block
+     *     sendNotificationToGerrit(event, buildResult);
+     * })
+     * .notClaimed(() -> {
+     *     // Optional: runs if claim was not acquired
+     *     logger.debug("Another instance is sending notification");
+     * })
+     * .onError((ex) -> {
+     *     // Optional: runs if an exception occurs during processing
+     *     logger.error("Failed to send notification", ex);
+     * });
+     * 
+ * + * @param event the Gerrit event to claim notification rights for + * @param claimed action to execute if claim succeeds (runs with claim held, auto-released) + * @return ClaimResult for chaining notClaimed/onError handlers */ - public abstract boolean tryClaimNotificationRight(@NonNull GerritTriggeredEvent event); + @NonNull + public abstract ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runnable claimed); /** - * Releases the notification claim for an event. - * Called after notification is sent or on error to clean up resources. + * Result of a notification claim attempt, allows chaining handlers for not-claimed and error cases. * - * @param event the Gerrit event + *

This interface supports a fluent API pattern for handling different outcomes + * of the claim attempt.

*/ - public abstract void releaseNotificationRight(@NonNull GerritTriggeredEvent event); + public interface ClaimResult { + /** + * Handler called if the claim was not acquired (another instance already sending notification). + * + *

This is optional - if not specified, nothing happens when the claim fails.

+ * + * @param notClaimed action to execute if claim failed + * @return this for chaining + */ + @NonNull + ClaimResult notClaimed(@NonNull Runnable notClaimed); + + /** + * Handler called if an exception occurs during notification sending. + * + *

This is optional - if not specified, exceptions are silently ignored + * (though they may be logged by the implementation).

+ * + * @param onError action to execute on error (receives the exception) + * @return this for chaining + */ + @NonNull + ClaimResult onError(@NonNull Consumer onError); + } } From 6b34efea042880919bf03ae7e095d5dc9f9e8771 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Wed, 20 May 2026 12:02:59 +0200 Subject: [PATCH 03/87] Fixing maven tests running in hazelcast mode --- pom.xml | 35 ++++++++ .../hazelcast/BuildCancelledProcessor.java | 2 + .../coordination/hazelcast/EntryData.java | 19 +++++ .../hazelcast/EntryDataSerializer.java | 2 + .../HazelcastBuildMemoryStorage.java | 18 ++++ .../hazelcast/HazelcastConfig.java | 36 +++++++- .../hazelcast/SetCancellingProcessor.java | 83 +++++++++++++++++++ .../gerritnotifier/model/BuildMemory.java | 15 +--- .../trigger/spi/BuildMemoryStorage.java | 17 ++++ .../storage/LocalBuildMemoryStorage.java | 11 +++ 10 files changed, 222 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCancellingProcessor.java diff --git a/pom.xml b/pom.xml index 0ad330356..7d85e9ecb 100644 --- a/pom.xml +++ b/pom.xml @@ -376,6 +376,41 @@ + + + + test-hazelcast + + + + maven-surefire-plugin + + false + + + hazelcast + + multicast + + + + + + + + scm:git:https://github.com/${gitHubRepo}.git scm:git:git@github.com:${gitHubRepo}.git diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java index aba35c363..51d4b43bf 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java @@ -61,6 +61,7 @@ public Boolean process(Map.Entry entry) { for (EntryData entryData : data.getEntries()) { if (projectFullName.equals(entryData.getProjectFullName())) { entryData.setCancelled(true); + entryData.setCancelling(false); // Clear cancelling flag entryData.setBuildCompleted(true); // Cancelled builds are also completed found = true; break; @@ -73,6 +74,7 @@ public Boolean process(Map.Entry entry) { EntryData newEntry = new EntryData(); newEntry.setProjectFullName(projectFullName); newEntry.setCancelled(true); + newEntry.setCancelling(false); newEntry.setBuildCompleted(true); // Cancelled builds are also completed data.addEntry(newEntry); } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java index 2b13775b1..e91ab55d6 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java @@ -37,6 +37,7 @@ public class EntryData { private String projectFullName; private String buildId; private boolean buildCompleted; + private boolean cancelling; private boolean cancelled; private String customUrl; private String unsuccessfulMessage; @@ -126,6 +127,24 @@ public void setBuildCompleted(boolean buildCompleted) { } } + /** + * Checks if build is being cancelled (cancellation intent). + * + * @return true if cancellation initiated + */ + public boolean isCancelling() { + return cancelling; + } + + /** + * Sets cancelling status (cancellation intent). + * + * @param cancelling cancelling status + */ + public void setCancelling(boolean cancelling) { + this.cancelling = cancelling; + } + /** * Checks if build was cancelled. * diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java index b5c87dc22..09221426f 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java @@ -49,6 +49,7 @@ public EntryData read(@NonNull CompactReader reader) { entry.setProjectFullName(reader.readString("projectFullName")); entry.setBuildId(reader.readString("buildId")); entry.setBuildCompleted(reader.readBoolean("buildCompleted")); + entry.setCancelling(reader.readBoolean("cancelling")); entry.setCancelled(reader.readBoolean("cancelled")); entry.setCustomUrl(reader.readString("customUrl")); entry.setUnsuccessfulMessage(reader.readString("unsuccessfulMessage")); @@ -63,6 +64,7 @@ public void write(@NonNull CompactWriter writer, @NonNull EntryData entry) { writer.writeString("projectFullName", entry.getProjectFullName()); writer.writeString("buildId", entry.getBuildId()); writer.writeBoolean("buildCompleted", entry.isBuildCompleted()); + writer.writeBoolean("cancelling", entry.isCancelling()); writer.writeBoolean("cancelled", entry.isCancelled()); writer.writeString("customUrl", entry.getCustomUrl()); writer.writeString("unsuccessfulMessage", entry.getUnsuccessfulMessage()); diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 656f64558..878f369c5 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -168,6 +168,7 @@ private MemoryImprint reconstructMemoryImprint(GerritTriggeredEvent event, Memor // Restore additional entry data MemoryImprint.Entry entry = imprint.getEntry(project); if (entry != null) { + entry.setCancelling(entryData.isCancelling()); entry.setCancelled(entryData.isCancelled()); entry.setCustomUrl(entryData.getCustomUrl()); entry.setUnsuccessfulMessage(entryData.getUnsuccessfulMessage()); @@ -343,6 +344,23 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull logger.trace("Cancelled event stored in distributed memory: {}", key); } + @Override + public synchronized void setCancelling(@NonNull GerritTriggeredEvent event, @NonNull Job project) { + IMap map = getDistributedMemory(); + if (map == null) { + logger.warn("Cannot mark cancelling - Hazelcast unavailable"); + return; + } + + BuildMemoryKey key = new BuildMemoryKey(event); + String projectFullName = project.getFullName(); + + // ATOMIC OPERATION - Executes on partition owner, prevents race conditions + map.executeOnKey(key, new SetCancellingProcessor(projectFullName)); + + logger.trace("Cancelling flag set in distributed memory for event: {}", key); + } + @Override public synchronized void forget(@NonNull GerritTriggeredEvent event) { IMap map = getDistributedMemory(); diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java index 2b83d7387..ef7c9ecb5 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java @@ -183,7 +183,10 @@ private static void configureNetwork(Config config) { String discoveryMode = System.getProperty(DISCOVERY_MODE_PROPERTY, "auto"); logger.info("Hazelcast discovery mode: {}", discoveryMode); - if ("kubernetes".equalsIgnoreCase(discoveryMode) || isKubernetesEnvironment()) { + if ("multicast".equalsIgnoreCase(discoveryMode)) { + // Multicast mode - primarily for testing + configureMulticastDiscovery(joinConfig); + } else if ("kubernetes".equalsIgnoreCase(discoveryMode) || isKubernetesEnvironment()) { configureKubernetesDiscovery(joinConfig, port); } else if ("tcp".equalsIgnoreCase(discoveryMode) || hasTcpMembersConfigured()) { configureTcpDiscovery(joinConfig); @@ -197,8 +200,10 @@ private static void configureNetwork(Config config) { } } - // Always disable multicast (not suitable for production) - joinConfig.getMulticastConfig().setEnabled(false); + // Disable multicast unless explicitly enabled + if (!"multicast".equalsIgnoreCase(discoveryMode)) { + joinConfig.getMulticastConfig().setEnabled(false); + } } /** @@ -255,6 +260,31 @@ private static void configureTcpDiscovery(JoinConfig joinConfig) { joinConfig.getAzureConfig().setEnabled(false); } + /** + * Configures multicast discovery. + *

+ * Warning: Multicast is NOT suitable for production use. + * This mode is primarily for testing purposes where a simple discovery + * mechanism is needed without Kubernetes or TCP configuration. + *

+ * Multicast allows Hazelcast instances on the same network segment to + * automatically discover each other. + * + * @param joinConfig the join configuration + */ + private static void configureMulticastDiscovery(JoinConfig joinConfig) { + logger.warn("Configuring multicast discovery - NOT SUITABLE FOR PRODUCTION, TESTING ONLY"); + + joinConfig.getMulticastConfig() + .setEnabled(true); + + // Disable other discovery methods + joinConfig.getTcpIpConfig().setEnabled(false); + joinConfig.getKubernetesConfig().setEnabled(false); + joinConfig.getAwsConfig().setEnabled(false); + joinConfig.getAzureConfig().setEnabled(false); + } + /** * Checks if running in Kubernetes environment. * diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCancellingProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCancellingProcessor.java new file mode 100644 index 000000000..d992c153c --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCancellingProcessor.java @@ -0,0 +1,83 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.map.EntryProcessor; + +import java.util.Map; + +/** + * Hazelcast Entry Processor to atomically set the "cancelling" flag for a project entry. + *

+ * This processor is used when the build cancellation policy decides a build should be cancelled, + * marking the intent before Jenkins actually processes the cancellation. + * The "cancelling" flag prevents the same build from being reconsidered for cancellation + * in future policy checks. + *

+ * Thread-safe atomic operation. + * + * @author CloudBees, Inc. + */ +public class SetCancellingProcessor implements EntryProcessor { + + private static final long serialVersionUID = 1L; + + private final String projectFullName; + + /** + * Constructor. + * + * @param projectFullName the full name of the project being marked for cancellation + */ + public SetCancellingProcessor(String projectFullName) { + this.projectFullName = projectFullName; + } + + @Override + public Object process(Map.Entry entry) { + MemoryImprintData data = entry.getValue(); + if (data == null) { + return null; + } + + // Find the entry for the project and set cancelling flag + boolean updated = false; + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + // Only set cancelling if not already completed, cancelling, or cancelled + if (!entryData.isBuildCompleted() && !entryData.isCancelling() && !entryData.isCancelled()) { + entryData.setCancelling(true); + updated = true; + } + } + } + + // Save changes if we updated anything + if (updated) { + entry.setValue(data); + } + + return null; + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index 7c2ee8846..5158c23ec 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -403,19 +403,8 @@ public void cancelOutdatedEvents( // in future cancellation checks (prevents state accumulation issues). // The actual "cancelled" flag will be set later by GerritQueueListener when Jenkins confirms. // - // IMPORTANT: We need to get the imprint from storage again to modify the real one, - // not the copy from getAllEvents() - MemoryImprint storageImprint = storage.getMemoryImprint(runningEvent); - if (storageImprint != null) { - for (Entry imprintEntry : storageImprint.getEntries()) { - if (imprintEntry.isProject(jobName) - && !imprintEntry.isBuildCompleted() - && !imprintEntry.isCancelling() - && !imprintEntry.isCancelled()) { - imprintEntry.setCancelling(true); - } - } - } + // Use storage.setCancelling() to persist the flag atomically + storage.setCancelling(runningEvent, job); } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java index 4df3cc722..e6506f738 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java @@ -124,6 +124,23 @@ public abstract void retriggered(@NonNull GerritTriggeredEvent event, @NonNull J */ public abstract void cancelled(@NonNull GerritTriggeredEvent event, @NonNull Job project); + /** + * Marks a build as "cancelling" (cancellation intent) for an event. + *

+ * This is called when the cancellation policy decides a build should be cancelled, + * but before Jenkins actually processes the cancellation. The "cancelling" flag + * prevents the same build from being considered for cancellation again in future + * policy checks (avoids state accumulation). + *

+ * The actual "cancelled" flag is set later by {@link #cancelled} when Jenkins + * confirms the build/queue item was actually cancelled. + * + * @param event the event + * @param project the project being marked for cancellation + * @see #cancelled(GerritTriggeredEvent, Job) + */ + public abstract void setCancelling(@NonNull GerritTriggeredEvent event, @NonNull Job project); + /** * Removes the memory for an event. *

diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java index 0ea8e1a8f..66b040d9d 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java @@ -121,6 +121,17 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull entry.setBuildCompleted(true); } + @Override + public synchronized void setCancelling(@NonNull GerritTriggeredEvent event, @NonNull Job project) { + MemoryImprint pb = getMemoryImprint(event); + if (pb != null) { + Entry entry = pb.getEntry(project); + if (entry != null) { + entry.setCancelling(true); + } + } + } + /** * Gets or creates a MemoryImprint for the given event. * From 51beeacb8f404a86669d9540fb8df22b1f4026b9 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Thu, 21 May 2026 11:53:31 +0200 Subject: [PATCH 04/87] First candidate for Hazelcast implementation --- pom.xml | 8 + .../plugins/gerrit/trigger/PluginImpl.java | 86 ++-- .../LocalCoordinationProvider.java | 20 + .../hazelcast/BuildMemoryKey.java | 9 +- .../hazelcast/EventIdentifier.java | 8 +- .../HazelcastBuildMemoryStorage.java | 80 ++-- .../HazelcastCoordinationProvider.java | 44 ++- .../PolymorphicEventTypeAdapter.java | 86 ++++ .../hazelcast/TriggeredProcessor.java | 90 +++++ .../gerritnotifier/model/BuildMemory.java | 15 +- .../trigger/spi/BuildMemoryStorage.java | 24 ++ .../trigger/spi/CoordinationModeProvider.java | 43 ++ .../storage/LocalBuildMemoryStorage.java | 6 + .../hazelcast/HazelcastTestHelper.java | 125 ++++++ .../hazelcast/HazelcastTestListener.java | 129 ++++++ .../hazelcast/HazelcastTestRule.java | 174 +++++++++ ...dCancellationHazelcastIntegrationTest.java | 366 ++++++++++++++++++ .../BuildCancellationIntegrationTest.java | 7 +- .../ReplicationQueueTaskDispatcherTest.java | 78 +++- .../spec/ParameterModeJenkinsTest.java | 12 + .../spec/SpecGerritTriggerHudsonTest.java | 2 + .../trigger/spec/VoteSameTopicTest.java | 2 + .../common/gerrit-trigger.xml | 27 ++ 23 files changed, 1352 insertions(+), 89 deletions(-) create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PolymorphicEventTypeAdapter.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/TriggeredProcessor.java create mode 100644 src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestHelper.java create mode 100644 src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestListener.java create mode 100644 src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestRule.java create mode 100644 src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationHazelcastIntegrationTest.java create mode 100644 src/test/resources/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationHazelcastIntegrationTest/common/gerrit-trigger.xml diff --git a/pom.xml b/pom.xml index 7d85e9ecb..ca19a68fa 100644 --- a/pom.xml +++ b/pom.xml @@ -398,12 +398,20 @@ maven-surefire-plugin false + 1 hazelcast multicast + + + + listener + com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastTestListener + + diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java index a52dceb16..bc0134dcb 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java @@ -581,10 +581,10 @@ public void start() { logger.trace("Loading configs"); load(); - // Initialize Hazelcast early (before any code that might use CoordinationModeFactory) + // Initialize coordination providers early (before any code that might use CoordinationModeFactory) // This must happen before BuildMemory, EventClaimStrategy, or NotificationClaimStrategy are used - // because HazelcastCoordinationProvider.isAvailable() checks if Hazelcast is initialized - initializeHazelcast(); + // because provider.isAvailable() may check if resources are initialized + initializeCoordinationProviders(); GerritSendCommandQueue.initialize(pluginConfig); gerritEventManager = new JenkinsAwareGerritHandler(pluginConfig.getNumberOfReceivingWorkerThreads()); @@ -595,38 +595,31 @@ public void start() { } /** - * Initialize Hazelcast if coordination mode is configured as 'hazelcast'. + * Initialize all coordination mode providers. *

* This is called early in plugin startup, before any code that might use - * CoordinationModeFactory. This ensures HazelcastCoordinationProvider.isAvailable() - * can see that Hazelcast is initialized and ready. + * CoordinationModeFactory. This ensures providers can initialize their resources + * and be ready when isAvailable() is called during provider discovery. *

- * Fails gracefully - if initialization fails, plugin continues in local mode. - */ - private void initializeHazelcast() { - String coordinationMode = System.getProperty("gerrit.trigger.coordination.mode", "local"); - - if (!"hazelcast".equalsIgnoreCase(coordinationMode)) { - logger.debug("Coordination mode is '{}', Hazelcast will not be initialized", coordinationMode); - return; - } - - logger.info("Coordination mode is 'hazelcast', initializing Hazelcast..."); - try { - boolean initialized = com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast - .HazelcastManager.initialize(); - if (initialized) { - logger.info("Hazelcast initialized successfully"); - String status = com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast - .HazelcastManager.getStatus(); - logger.info("Hazelcast status: {}", status); - } else { - logger.warn("Hazelcast initialization returned false (mode may be disabled)"); + * Fails gracefully - if a provider's initialization fails, it will not be available + * and the factory will fall back to the next highest-priority provider. + */ + private void initializeCoordinationProviders() { + logger.debug("Initializing coordination providers..."); + for (com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider provider + : hudson.ExtensionList.lookup( + com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider.class)) { + try { + logger.debug("Initializing provider: {}", provider.getModeName()); + provider.initialize(); + logger.debug("Provider {} initialized successfully", provider.getModeName()); + } catch (Exception e) { + logger.warn("Failed to initialize coordination provider: {}. " + + "Provider will not be available.", provider.getModeName(), e); + // Continue with other providers even if one fails } - } catch (Exception e) { - logger.error("Failed to initialize Hazelcast. Plugin will continue in local mode.", e); - // Continue plugin startup even if Hazelcast fails - will fall back to local mode } + logger.debug("Coordination provider initialization complete"); } /** @@ -713,9 +706,9 @@ protected static void doXStreamRegistrations() { public void stop() { active = false; - // Shutdown Hazelcast before stopping servers + // Shutdown coordination providers before stopping servers // This ensures any coordination operations are cleaned up before servers disconnect - shutdownHazelcast(); + shutdownCoordinationProviders(); for (GerritServer s : servers) { s.stop(); @@ -730,24 +723,27 @@ public void stop() { } /** - * Shutdown Hazelcast if it was initialized. + * Shutdown all coordination mode providers. *

- * Called during plugin shutdown to clean up Hazelcast resources. + * Called during plugin shutdown to clean up coordination resources. * Fails gracefully - errors are logged but don't prevent plugin shutdown. */ - private void shutdownHazelcast() { - try { - if (com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast - .HazelcastManager.isInitialized()) { - logger.info("Shutting down Hazelcast..."); - com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast - .HazelcastManager.shutdown(); - logger.info("Hazelcast shutdown complete"); + private void shutdownCoordinationProviders() { + logger.debug("Shutting down coordination providers..."); + for (com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider provider + : hudson.ExtensionList.lookup( + com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider.class)) { + try { + logger.debug("Shutting down provider: {}", provider.getModeName()); + provider.shutdown(); + logger.debug("Provider {} shut down successfully", provider.getModeName()); + } catch (Exception e) { + logger.warn("Error shutting down coordination provider: {} (non-critical, continuing shutdown)", + provider.getModeName(), e); + // Continue with other providers even if one fails } - } catch (Exception e) { - logger.error("Error shutting down Hazelcast (non-critical, continuing shutdown)", e); - // Continue with plugin shutdown even if Hazelcast shutdown fails } + logger.debug("Coordination provider shutdown complete"); } /** diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java index 11a8ef0e8..63c1c8194 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java @@ -114,4 +114,24 @@ public NotificationClaimStrategy createClaimStrategy() { public EventClaimStrategy createEventClaimStrategy() { return new LocalEventClaimStrategy(); } + + /** + * Initializes local coordination mode. + *

+ * Local mode requires no initialization - no external resources to set up. + */ + @Override + public void initialize() { + // Local mode needs no initialization + } + + /** + * Shuts down local coordination mode. + *

+ * Local mode requires no shutdown - no external resources to release. + */ + @Override + public void shutdown() { + // Local mode needs no shutdown + } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildMemoryKey.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildMemoryKey.java index fe9e79632..22c1b5277 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildMemoryKey.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildMemoryKey.java @@ -38,7 +38,14 @@ public class BuildMemoryKey implements Serializable { private static final long serialVersionUID = 1L; - private final String eventId; + private String eventId; + + /** + * No-arg constructor for serialization. + */ + public BuildMemoryKey() { + this.eventId = null; + } /** * Constructor from GerritTriggeredEvent. diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java index 20742bc33..bdfd2b87d 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java @@ -88,7 +88,7 @@ public static String generateEventId(GerritTriggeredEvent event) { * Generates ID for change-based events (patchset-created, comment-added, etc.). * * @param event the change-based event - * @return event ID in format: change-{number}-{patchset}-{type}-{timestamp} + * @return event ID in format: change-{project}-{number}-{patchset}-{type}-{timestamp} */ private static String generateChangeBasedEventId(ChangeBasedEvent event) { Change change = event.getChange(); @@ -102,8 +102,10 @@ private static String generateChangeBasedEventId(ChangeBasedEvent event) { // Fall back to receivedOn if eventCreatedOn is not available long timestamp = getEventTimestamp(event); - // Format: change---- - return String.format("change-%s-%s-%s-%d", + // Format: change-{project}-{number}-{patchset}-{type}-{timestamp} + // Project is included because change numbers are only unique within a project + return String.format("change-%s-%s-%s-%s-%d", + sanitize(change.getProject()), change.getNumber(), patchSet.getNumber(), sanitizeEventType(event.getEventType().getTypeValue()), diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 878f369c5..97d18be06 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -73,8 +73,11 @@ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { /** * Gson instance for JSON serialization of events. + * Configured to handle polymorphic event types by including runtime type information. */ - private static final Gson GSON = new GsonBuilder().create(); + private static final Gson GSON = new GsonBuilder() + .registerTypeAdapter(GerritTriggeredEvent.class, new PolymorphicEventTypeAdapter()) + .create(); /** * Distributed mode storage (coordination mode). @@ -92,7 +95,8 @@ private IMap getDistributedMemory() { HazelcastInstance hz = HazelcastInstanceProvider.getInstance(); if (hz != null) { distributedMemory = hz.getMap(MAP_NAME); - logger.debug("Initialized distributed BuildMemory map: {}", MAP_NAME); + logger.debug("Initialized distributed BuildMemory map: {} (size: {})", + MAP_NAME, distributedMemory.size()); } else { logger.warn("Hazelcast unavailable, distributed memory not available"); } @@ -108,7 +112,15 @@ private IMap getDistributedMemory() { */ private String serializeEvent(GerritTriggeredEvent event) { try { - return GSON.toJson(event); + // IMPORTANT: Must explicitly specify GerritTriggeredEvent.class to ensure + // the PolymorphicEventTypeAdapter is used, even when event is a concrete subclass + String json = GSON.toJson(event, GerritTriggeredEvent.class); + if (json != null) { + logger.trace("Serialized event {} to JSON (length: {})", event, json.length()); + } else { + logger.trace("Serialized event {} to JSON (length: 0)", event); + } + return json; } catch (Exception e) { logger.error("Failed to serialize event to JSON: " + event, e); return null; @@ -123,9 +135,19 @@ private String serializeEvent(GerritTriggeredEvent event) { */ private GerritTriggeredEvent deserializeEvent(String eventJson) { try { - return GSON.fromJson(eventJson, GerritTriggeredEvent.class); + if (eventJson == null) { + logger.warn("Cannot deserialize null eventJson"); + return null; + } + GerritTriggeredEvent event = GSON.fromJson(eventJson, GerritTriggeredEvent.class); + logger.trace("Deserialized JSON (length: {}) to event: {}", eventJson.length(), event); + return event; } catch (Exception e) { - logger.error("Failed to deserialize event from JSON", e); + if (eventJson != null) { + logger.error("Failed to deserialize event from JSON (length: " + eventJson.length() + ")", e); + } else { + logger.error("Failed to deserialize event from NULL JSON", e); + } return null; } } @@ -168,6 +190,7 @@ private MemoryImprint reconstructMemoryImprint(GerritTriggeredEvent event, Memor // Restore additional entry data MemoryImprint.Entry entry = imprint.getEntry(project); if (entry != null) { + entry.setBuildCompleted(entryData.isBuildCompleted()); entry.setCancelling(entryData.isCancelling()); entry.setCancelled(entryData.isCancelled()); entry.setCustomUrl(entryData.getCustomUrl()); @@ -207,21 +230,20 @@ public synchronized void triggered(@NonNull GerritTriggeredEvent event, @NonNull } BuildMemoryKey key = new BuildMemoryKey(event); - MemoryImprintData data = map.get(key); - - if (data == null) { - // Create new memory imprint data - data = new MemoryImprintData(); - data.setEventJson(serializeEvent(event)); - } + String projectFullName = project.getFullName(); + String eventJson = serializeEvent(event); - // Add entry for triggered project - EntryData entryData = new EntryData(); - entryData.setProjectFullName(project.getFullName()); - data.addEntry(entryData); + // ATOMIC OPERATION - Executes on partition owner, prevents race conditions + // This is critical when multiple projects are triggered by the same event simultaneously. + // Without atomic operations, concurrent threads can overwrite each other's entries, + // causing some project entries to be lost from BuildMemory (which breaks cancellation logic). + Boolean wasNew = map.executeOnKey(key, new TriggeredProcessor(projectFullName, eventJson)); - map.put(key, data); - logger.trace("Triggered event stored in distributed memory: {}", key); + if (wasNew) { + logger.trace("Triggered event stored in distributed memory: {} for project: {}", key, projectFullName); + } else { + logger.trace("Project {} already triggered for event: {}", projectFullName, key); + } } @Override @@ -573,12 +595,26 @@ public synchronized Map getAllEvents() { // Convert all entries for (Map.Entry entry : map.entrySet()) { - GerritTriggeredEvent event = deserializeEvent(entry.getValue().getEventJson()); - if (event != null) { - MemoryImprint imprint = reconstructMemoryImprint(event, entry.getValue()); - result.put(event, imprint); + MemoryImprintData data = entry.getValue(); + if (data != null) { + GerritTriggeredEvent event = deserializeEvent(data.getEventJson()); + if (event != null) { + MemoryImprint imprint = reconstructMemoryImprint(event, data); + result.put(event, imprint); + } } } + + logger.trace("Returning {} events from distributed memory", result.size()); return result; } + + @Override + public boolean eventsMatch(@NonNull GerritTriggeredEvent event1, @NonNull GerritTriggeredEvent event2) { + // In distributed mode, use logical comparison via EventIdentifier + // because events may be deserialized from Hazelcast, creating new object instances + String id1 = EventIdentifier.generateEventId(event1); + String id2 = EventIdentifier.generateEventId(event2); + return id1.equals(id2); + } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java index 9011ef167..05389406a 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java @@ -55,7 +55,7 @@ * All three coordination concerns (build state storage, notification rights, event processing rights) * now use the same Extension Points pattern consistently. * - * @see CoordinationModeFactory + * @see com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.CoordinationModeFactory * @see com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.LocalCoordinationProvider (fallback) * @author CloudBees, Inc. */ @@ -170,4 +170,46 @@ public EventClaimStrategy createEventClaimStrategy() { logger.info("Creating HazelcastEventClaimStrategy"); return new HazelcastEventClaimStrategy(); } + + /** + * Initializes Hazelcast coordination mode. + *

+ * Only initializes if coordination mode is configured as 'hazelcast'. + * This ensures Hazelcast is not started when using local mode. + *

+ * If initialization fails, an exception is thrown and the provider will + * not be available (isAvailable() will return false). + * + * @throws Exception if Hazelcast initialization fails + */ + @Override + public void initialize() throws Exception { + // Check coordination mode - only initialize if this provider should be used + String configuredMode = getConfiguredMode(); + if (!HAZELCAST_MODE.equalsIgnoreCase(configuredMode)) { + logger.trace("Coordination mode is '{}', skipping Hazelcast initialization", configuredMode); + return; + } + + logger.info("Initializing Hazelcast coordination mode..."); + boolean initialized = HazelcastManager.initialize(); + if (initialized) { + logger.info("Hazelcast initialized successfully"); + } else { + logger.warn("Hazelcast initialization returned false - may already be initialized"); + } + } + + /** + * Shuts down Hazelcast coordination mode. + *

+ * This gracefully shuts down the Hazelcast instance, leaving the cluster + * and releasing all resources. + */ + @Override + public void shutdown() { + logger.info("Shutting down Hazelcast coordination mode..."); + HazelcastManager.shutdown(); + logger.info("Hazelcast shut down complete"); + } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PolymorphicEventTypeAdapter.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PolymorphicEventTypeAdapter.java new file mode 100644 index 000000000..641c02271 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PolymorphicEventTypeAdapter.java @@ -0,0 +1,86 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; + +import java.lang.reflect.Type; + +/** + * Custom Gson type adapter for polymorphic GerritTriggeredEvent serialization. + *

+ * Handles serialization and deserialization of GerritTriggeredEvent subclasses + * by including type information in the JSON. This allows proper reconstruction + * of the correct concrete event type when deserializing from Hazelcast. + *

+ * JSON format: + *

+ * {
+ *   "@type": "com.sonymobile.tools.gerrit.gerritevents.dto.events.PatchsetCreated",
+ *   "data": { ... actual event properties ... }
+ * }
+ * 
+ * + */ +public class PolymorphicEventTypeAdapter + implements JsonSerializer, JsonDeserializer { + + private static final String TYPE_FIELD = "@type"; + private static final String DATA_FIELD = "data"; + + @Override + public JsonElement serialize(GerritTriggeredEvent src, Type typeOfSrc, JsonSerializationContext context) { + JsonObject result = new JsonObject(); + result.addProperty(TYPE_FIELD, src.getClass().getName()); + result.add(DATA_FIELD, context.serialize(src, src.getClass())); + return result; + } + + @Override + public GerritTriggeredEvent deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + JsonObject jsonObject = json.getAsJsonObject(); + + if (!jsonObject.has(TYPE_FIELD)) { + throw new JsonParseException("Missing type field '" + TYPE_FIELD + "' in JSON"); + } + + String className = jsonObject.get(TYPE_FIELD).getAsString(); + JsonElement data = jsonObject.get(DATA_FIELD); + + try { + Class clazz = Class.forName(className); + return context.deserialize(data, clazz); + } catch (ClassNotFoundException e) { + throw new JsonParseException("Unknown event type: " + className, e); + } + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/TriggeredProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/TriggeredProcessor.java new file mode 100644 index 000000000..ea8823eb4 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/TriggeredProcessor.java @@ -0,0 +1,90 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.map.EntryProcessor; + +import java.util.Map; + +/** + * Hazelcast EntryProcessor for atomically recording a triggered build. + *

+ * This processor prevents the "lost update" race condition that occurs when multiple + * projects are triggered by the same Gerrit event simultaneously. Without atomic operations, + * concurrent threads can overwrite each other's entries, causing some project entries to be lost + * from BuildMemory. + *

+ * Executes on the partition owner to ensure atomicity across distributed Hazelcast cluster. + * + */ +public class TriggeredProcessor implements EntryProcessor { + private static final long serialVersionUID = 1L; + + private final String projectFullName; + private final String eventJson; + + /** + * Constructor. + * + * @param projectFullName the full name of the project + * @param eventJson the serialized JSON representation of the event + */ + public TriggeredProcessor(String projectFullName, String eventJson) { + this.projectFullName = projectFullName; + this.eventJson = eventJson; + } + + @Override + public Boolean process(Map.Entry entry) { + MemoryImprintData data = entry.getValue(); + + // Create new data if this is the first project triggered by this event + if (data == null) { + data = new MemoryImprintData(); + data.setEventJson(eventJson); + } + + // Check if this project is already recorded (idempotency check) + boolean found = false; + if (data.getEntries() != null) { + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + found = true; + break; + } + } + } + + // Add entry for this project if not already present + if (!found) { + EntryData newEntry = new EntryData(); + newEntry.setProjectFullName(projectFullName); + data.addEntry(newEntry); + } + + // Save the modified data back atomically + entry.setValue(data); + return !found; // Return true if this was a new entry, false if already existed + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index 5158c23ec..9bb7d6fd1 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -550,16 +550,25 @@ private void cancelMatchingJobs( /** * Checks if any of the given causes references the given event. * Ported from RunningJobs.checkCausedByGerrit(). + *

+ * Important: Event comparison is delegated to the storage implementation + * via {@link BuildMemoryStorage#eventsMatch(GerritTriggeredEvent, GerritTriggeredEvent)}. + * This respects the abstraction boundary: + *

    + *
  • Local mode: Uses identity comparison (==)
  • + *
  • Distributed mode: Uses logical comparison via EventIdentifier + * since events may be deserialized
  • + *
* - * @param event the event to check for (checks for identity, not equality) + * @param event the event to check for * @param causes the list of causes - * @return true if the list contains a GerritCause with this event + * @return true if the list contains a GerritCause with an equivalent event */ private boolean checkCausedByGerrit(GerritTriggeredEvent event, Collection causes) { for (Cause c : causes) { if (c instanceof GerritCause) { GerritCause gc = (GerritCause)c; - if (gc.getEvent() == event) { + if (storage.eventsMatch(event, gc.getEvent())) { return true; } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java index e6506f738..67b284e3c 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java @@ -286,4 +286,28 @@ public abstract void setEntryUnsuccessfulMessage(@NonNull GerritTriggeredEvent e */ @NonNull public abstract Map getAllEvents(); + + /** + * Checks if two events are logically equivalent. + *

+ * This method allows each storage implementation to define its own event equality + * semantics. This is critical for proper operation in different coordination modes: + *

    + *
  • Local mode: Uses identity comparison (==) since events are + * never serialized/deserialized
  • + *
  • Distributed mode: Uses logical comparison via EventIdentifier + * since events are serialized/deserialized across replicas
  • + *
+ *

+ * Design rationale: Event equality semantics belong in the storage + * layer, not in business logic (BuildMemory). This respects the abstraction boundary + * and allows future coordination modes to define their own comparison strategy without + * modifying BuildMemory. + * + * @param event1 the first event + * @param event2 the second event + * @return true if the events are logically equivalent according to this storage + */ + public abstract boolean eventsMatch(@NonNull GerritTriggeredEvent event1, + @NonNull GerritTriggeredEvent event2); } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java index bdcf1a506..0d8d4cefd 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java @@ -162,4 +162,47 @@ public static String getConfiguredMode() { * @return a new EventClaimStrategy instance (non-null) */ public abstract EventClaimStrategy createEventClaimStrategy(); + + /** + * Initializes this coordination mode provider. + * + *

Called during plugin startup (PluginImpl.start()) to initialize any resources + * needed by this provider. For example:

+ *
    + *
  • Local mode: no-op (no initialization needed)
  • + *
  • Hazelcast mode: initializes Hazelcast instance and cluster membership
  • + *
  • Redis mode: establishes connection pool
  • + *
  • JDBC mode: initializes database connection
  • + *
+ * + *

IMPORTANT: This method is called BEFORE the provider is selected by + * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.CoordinationModeFactory}. + * The provider must be fully initialized when {@link #isAvailable()} is called during + * provider discovery.

+ * + *

Error Handling: If initialization fails, implementations should throw an + * exception. The plugin will log the error and continue, allowing {@link #isAvailable()} + * to return false so a fallback provider can be selected.

+ * + * @throws Exception if initialization fails + */ + public abstract void initialize() throws Exception; + + /** + * Shuts down this coordination mode provider. + * + *

Called during plugin shutdown (PluginImpl.stop()) to release any resources + * held by this provider. For example:

+ *
    + *
  • Local mode: no-op (no resources to release)
  • + *
  • Hazelcast mode: shuts down Hazelcast instance gracefully
  • + *
  • Redis mode: closes connection pool
  • + *
  • JDBC mode: closes database connections
  • + *
+ * + *

Error Handling: Implementations should handle errors gracefully and + * not throw exceptions, as this is called during shutdown and exceptions cannot + * be meaningfully handled.

+ */ + public abstract void shutdown(); } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java index 66b040d9d..3c5159650 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java @@ -329,4 +329,10 @@ public synchronized Map getAllEvents() { // Return a copy to avoid concurrent modification issues return new TreeMap<>(memory); } + + @Override + public boolean eventsMatch(@NonNull GerritTriggeredEvent event1, @NonNull GerritTriggeredEvent event2) { + // In local mode, use identity comparison since events are never serialized + return event1 == event2; + } } diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestHelper.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestHelper.java new file mode 100644 index 000000000..dd1dd12fe --- /dev/null +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestHelper.java @@ -0,0 +1,125 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.map.IMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Test helper utilities for Hazelcast tests. + *

+ * Provides methods to clear Hazelcast state between tests, preventing + * state pollution when tests run in sequence with a shared Hazelcast instance. + * + */ +public final class HazelcastTestHelper { + + private static final Logger logger = LoggerFactory.getLogger(HazelcastTestHelper.class); + + /** + * The name of the BuildMemory distributed map in Hazelcast. + */ + private static final String BUILD_MEMORY_MAP_NAME = "gerrit-trigger-build-memory"; + + /** + * Private constructor to prevent instantiation. + */ + private HazelcastTestHelper() { + // Utility class + } + + /** + * Clears all Hazelcast distributed maps used by Gerrit Trigger. + *

+ * This should be called in @After methods to ensure clean state between tests. + * Safe to call even if Hazelcast is not initialized. + */ + public static void clearAllMaps() { + if (!HazelcastInstanceProvider.isInitialized()) { + logger.debug("Hazelcast not initialized, nothing to clear"); + return; + } + + try { + HazelcastInstance instance = HazelcastInstanceProvider.getInstance(); + + // Clear BuildMemory map + IMap buildMemoryMap = instance.getMap(BUILD_MEMORY_MAP_NAME); + int entries = buildMemoryMap.size(); + buildMemoryMap.clear(); + logger.debug("Cleared {} entries from BuildMemory map", entries); + + } catch (Exception e) { + logger.warn("Error clearing Hazelcast maps (test cleanup)", e); + // Don't fail tests on cleanup errors + } + } + + /** + * Clears only the BuildMemory distributed map. + *

+ * Useful when you want to clear build state but keep other state intact. + * Safe to call even if Hazelcast is not initialized. + */ + public static void clearBuildMemory() { + if (!HazelcastInstanceProvider.isInitialized()) { + logger.debug("Hazelcast not initialized, nothing to clear"); + return; + } + + try { + HazelcastInstance instance = HazelcastInstanceProvider.getInstance(); + IMap buildMemoryMap = instance.getMap(BUILD_MEMORY_MAP_NAME); + int entries = buildMemoryMap.size(); + buildMemoryMap.clear(); + logger.debug("Cleared {} entries from BuildMemory map", entries); + } catch (Exception e) { + logger.warn("Error clearing BuildMemory map (test cleanup)", e); + // Don't fail tests on cleanup errors + } + } + + /** + * Gets the number of entries in the BuildMemory map. + * Useful for debugging test failures. + * + * @return number of entries, or 0 if Hazelcast not initialized + */ + public static int getBuildMemorySize() { + if (!HazelcastInstanceProvider.isInitialized()) { + return 0; + } + + try { + HazelcastInstance instance = HazelcastInstanceProvider.getInstance(); + IMap buildMemoryMap = instance.getMap(BUILD_MEMORY_MAP_NAME); + return buildMemoryMap.size(); + } catch (Exception e) { + logger.warn("Error getting BuildMemory size", e); + return 0; + } + } +} diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestListener.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestListener.java new file mode 100644 index 000000000..b0710b4c2 --- /dev/null +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestListener.java @@ -0,0 +1,129 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import org.junit.runner.Description; +import org.junit.runner.Result; +import org.junit.runner.notification.RunListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * JUnit test listener that initializes Hazelcast once for the entire test suite. + *

+ * This listener is automatically invoked by Maven Surefire when the test-hazelcast + * profile is active. It ensures Hazelcast is initialized before any tests run, + * allowing all tests to run in distributed coordination mode. + *

+ * Configuration: Activated via Maven profile with test-hazelcast profile. + * + */ +public class HazelcastTestListener extends RunListener { + + private static final Logger logger = LoggerFactory.getLogger(HazelcastTestListener.class); + + private static final String COORDINATION_MODE_PROPERTY = "gerrit.trigger.coordination.mode"; + private static final String HAZELCAST_MODE = "hazelcast"; + + private static boolean initialized = false; + private static boolean shouldInitialize = false; + + /** + * Called once before any tests run. + * Initializes Hazelcast if coordination mode is set to hazelcast. + * + * @param description test run description + */ + @Override + public void testRunStarted(Description description) { + String mode = System.getProperty(COORDINATION_MODE_PROPERTY); + + if (HAZELCAST_MODE.equalsIgnoreCase(mode)) { + logger.info("=== Hazelcast Test Suite Initialization ==="); + logger.info("Coordination mode property: {}", mode); + + if (!HazelcastManager.isInitialized()) { + try { + logger.info("Initializing Hazelcast for test suite..."); + boolean success = HazelcastManager.initialize(); + + if (success) { + initialized = true; + shouldInitialize = true; + logger.info("Hazelcast initialized successfully for test suite"); + logger.info("Cluster: {}", + HazelcastInstanceProvider.getInstance().getConfig().getClusterName()); + logger.info("Instance: {}", + HazelcastInstanceProvider.getInstance().getName()); + logger.info("Members: {}", + HazelcastInstanceProvider.getInstance().getCluster().getMembers().size()); + } else { + logger.error("Failed to initialize Hazelcast for test suite"); + } + } catch (Exception e) { + logger.error("Exception initializing Hazelcast for test suite", e); + } + } else { + logger.info("Hazelcast already initialized"); + initialized = true; + } + + logger.info("=== Hazelcast Test Suite Initialization Complete ==="); + } else { + logger.debug("Coordination mode is '{}', Hazelcast listener will not initialize", mode); + } + } + + /** + * Called once after all tests complete. + * Shuts down Hazelcast if it was initialized by this listener. + * + * @param result test run result + */ + @Override + public void testRunFinished(Result result) { + if (shouldInitialize && initialized) { + logger.info("=== Hazelcast Test Suite Cleanup ==="); + try { + logger.info("Shutting down Hazelcast..."); + HazelcastManager.shutdown(); + initialized = false; + shouldInitialize = false; + logger.info("Hazelcast shutdown complete"); + } catch (Exception e) { + logger.error("Error shutting down Hazelcast", e); + } + logger.info("=== Hazelcast Test Suite Cleanup Complete ==="); + } + } + + /** + * Checks if Hazelcast was initialized by this listener. + * + * @return true if initialized + */ + public static boolean isInitialized() { + return initialized; + } +} diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestRule.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestRule.java new file mode 100644 index 000000000..620f3346a --- /dev/null +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestRule.java @@ -0,0 +1,174 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import org.junit.rules.ExternalResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * JUnit rule for initializing and cleaning up Hazelcast for tests. + *

+ * This rule handles the complete lifecycle of Hazelcast for integration tests: + *

    + *
  • Sets coordination mode system property
  • + *
  • Initializes Hazelcast embedded instance
  • + *
  • Verifies Hazelcast is ready
  • + *
  • Cleans up after tests complete
  • + *
+ * + *

Usage:

+ *
{@code
+ * public class MyHazelcastTest {
+ *     @Rule
+ *     public HazelcastTestRule hazelcast = new HazelcastTestRule();
+ *
+ *     @Rule
+ *     public JenkinsRule jenkins = new JenkinsRule();
+ *
+ *     @Test
+ *     public void testWithHazelcast() {
+ *         // Test will run with Hazelcast coordination mode active
+ *     }
+ * }
+ * }
+ * + *

Important: This rule must be declared BEFORE JenkinsRule + * in the test class to ensure Hazelcast is initialized before Jenkins starts.

+ * + */ +public class HazelcastTestRule extends ExternalResource { + + private static final Logger logger = LoggerFactory.getLogger(HazelcastTestRule.class); + + private static final String COORDINATION_MODE_PROPERTY = "gerrit.trigger.coordination.mode"; + private static final String HAZELCAST_MODE = "hazelcast"; + + private String originalModeValue; + private boolean initializedByThisRule = false; + + /** + * Sets up Hazelcast before the test runs. + *

+ * This method: + *

    + *
  1. Saves the original coordination mode property
  2. + *
  3. Sets coordination mode to "hazelcast"
  4. + *
  5. Initializes Hazelcast embedded instance
  6. + *
  7. Verifies initialization succeeded
  8. + *
+ * + * @throws Exception if Hazelcast initialization fails + */ + @Override + protected void before() throws Exception { + logger.info("=== Hazelcast Test Setup START ==="); + + // Save original property value + originalModeValue = System.getProperty(COORDINATION_MODE_PROPERTY); + logger.info("Original coordination mode: {}", originalModeValue); + + // Set coordination mode to hazelcast + System.setProperty(COORDINATION_MODE_PROPERTY, HAZELCAST_MODE); + logger.info("Set coordination mode to: {}", HAZELCAST_MODE); + + // Initialize Hazelcast if not already initialized + if (!HazelcastManager.isInitialized()) { + logger.info("Initializing Hazelcast for test..."); + boolean success = HazelcastManager.initialize(); + + if (!success) { + throw new IllegalStateException("Failed to initialize Hazelcast for test"); + } + + initializedByThisRule = true; + logger.info("Hazelcast initialized successfully"); + } else { + logger.info("Hazelcast already initialized, reusing existing instance"); + initializedByThisRule = false; + } + + // Verify Hazelcast is available + if (!HazelcastInstanceProvider.isInitialized()) { + throw new IllegalStateException("Hazelcast instance not available after initialization"); + } + + logger.info("Hazelcast instance: {}", HazelcastInstanceProvider.getInstance().getName()); + logger.info("Cluster size: {}", + HazelcastInstanceProvider.getInstance().getCluster().getMembers().size()); + logger.info("=== Hazelcast Test Setup COMPLETE ==="); + } + + /** + * Cleans up Hazelcast after the test completes. + *

+ * This method: + *

    + *
  1. Shuts down Hazelcast (if initialized by this rule)
  2. + *
  3. Restores original coordination mode property
  4. + *
+ *

+ * Cleanup is best-effort - errors are logged but don't fail the test. + */ + @Override + protected void after() { + logger.info("=== Hazelcast Test Cleanup START ==="); + + try { + // Only shutdown if we initialized it + if (initializedByThisRule && HazelcastManager.isInitialized()) { + logger.info("Shutting down Hazelcast..."); + HazelcastManager.shutdown(); + logger.info("Hazelcast shutdown complete"); + } else if (!initializedByThisRule) { + logger.info("Hazelcast was not initialized by this rule, leaving it running"); + } else { + logger.info("Hazelcast already shut down"); + } + } catch (Exception e) { + logger.error("Error shutting down Hazelcast (test cleanup)", e); + // Don't fail the test on cleanup errors + } + + // Restore original property + if (originalModeValue == null) { + System.clearProperty(COORDINATION_MODE_PROPERTY); + logger.info("Cleared coordination mode property"); + } else { + System.setProperty(COORDINATION_MODE_PROPERTY, originalModeValue); + logger.info("Restored coordination mode to: {}", originalModeValue); + } + + logger.info("=== Hazelcast Test Cleanup COMPLETE ==="); + } + + /** + * Checks if Hazelcast was initialized by this rule. + * + * @return true if this rule initialized Hazelcast + */ + public boolean wasInitializedByThisRule() { + return initializedByThisRule; + } +} diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationHazelcastIntegrationTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationHazelcastIntegrationTest.java new file mode 100644 index 000000000..f8ea28a52 --- /dev/null +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationHazelcastIntegrationTest.java @@ -0,0 +1,366 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger; + +import com.sonyericsson.hudson.plugins.gerrit.trigger.GerritServer; +import com.sonyericsson.hudson.plugins.gerrit.trigger.Messages; +import com.sonyericsson.hudson.plugins.gerrit.trigger.PluginImpl; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.CoordinationModeFactory; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastTestHelper; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastTestRule; +import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.BuildCancellationPolicy; +import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.Branch; +import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.CompareType; +import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.GerritProject; +import com.sonyericsson.hudson.plugins.gerrit.trigger.mock.Setup; +import com.sonyericsson.hudson.plugins.gerrit.trigger.mock.TestUtils; +import com.sonymobile.tools.gerrit.gerritevents.dto.events.ChangeAbandoned; +import com.sonymobile.tools.gerrit.gerritevents.dto.events.PatchsetCreated; +import com.sonymobile.tools.gerrit.gerritevents.mock.SshdServerMock; + +import java.util.Collections; +import hudson.model.FreeStyleBuild; +import hudson.model.FreeStyleProject; +import hudson.model.Result; +import org.apache.sshd.server.SshServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.BuildWatcher; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.SleepBuilder; +import org.jvnet.hudson.test.recipes.LocalData; + +import static com.sonymobile.tools.gerrit.gerritevents.mock.SshdServerMock.GERRIT_STREAM_EVENTS; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Integration tests for build cancellation in Hazelcast distributed mode. + *

+ * These tests verify that build cancellation works correctly when using + * Hazelcast-backed BuildMemoryStorage instead of local TreeMap storage. + *

+ * This is critical for CloudBees HA/HS deployments where multiple Jenkins + * instances share state via Hazelcast. + * + */ +public class BuildCancellationHazelcastIntegrationTest { + + /** + * Hazelcast test infrastructure - MUST be declared before JenkinsRule. + * This ensures Hazelcast is initialized before Jenkins starts. + */ + //CS IGNORE VisibilityModifier FOR NEXT 2 LINES. REASON: JUnit Rule. + @Rule + public final HazelcastTestRule hazelcast = new HazelcastTestRule(); + + /** + * An instance of Jenkins Rule. + */ + //CS IGNORE VisibilityModifier FOR NEXT 2 LINES. REASON: JenkinsRule. + @Rule + public final JenkinsRule jenkins = new JenkinsRule(); + + /** + * Outputs build logs to std out. + */ + //CS IGNORE VisibilityModifier FOR NEXT 2 LINES. REASON: JenkinsRule. + @Rule + public final BuildWatcher watcher = new BuildWatcher(); + + private SshServer sshd; + private SshdServerMock serverMock; + private GerritServer gerritServer; + private static final int BUILD_TIMEOUT = 30000; + private static final int SERVER_WAIT = 2000; + private static final int SLEEP_10_SEC = 10000; + private static final int SLEEP_HALF_SEC = 500; + + /** + * Sets up the SSH server mock before each test. + * + * @throws Exception if setup fails + */ + @Before + public void setUp() throws Exception { + SshdServerMock.generateKeyPair(); + serverMock = new SshdServerMock(); + sshd = SshdServerMock.startServer(serverMock); + serverMock.returnCommandFor("gerrit ls-projects", SshdServerMock.EofCommandMock.class); + serverMock.returnCommandFor(GERRIT_STREAM_EVENTS, SshdServerMock.CommandMock.class); + serverMock.returnCommandFor("gerrit review.*", SshdServerMock.EofCommandMock.class); + serverMock.returnCommandFor("gerrit version", SshdServerMock.SendVersionCommand.class); + gerritServer = PluginImpl.getFirstServer_(); + if (gerritServer != null) { + SshdServerMock.configureFor(sshd, gerritServer, true); + } + + // Verify Hazelcast mode is active + verifyHazelcastMode(); + } + + /** + * Tears down the SSH server. + * + * @throws Exception if teardown fails + */ + @After + public void tearDown() throws Exception { + // Clear Hazelcast state to prevent pollution between tests + HazelcastTestHelper.clearAllMaps(); + + if (sshd != null) { + sshd.stop(true); + sshd = null; + } + } + + /** + * Verifies that Hazelcast coordination mode is actually active. + * This prevents false positives from tests running in local mode. + */ + private void verifyHazelcastMode() { + CoordinationModeFactory factory = CoordinationModeFactory.get(); + + // Trigger mode discovery by accessing storage + String storageClass = factory.getStorage().getClass().getSimpleName(); + + // Now get the selected mode (will not be null after storage access) + String modeName; + if (factory.getSelectedMode() != null) { + modeName = factory.getSelectedMode().getModeName(); + } else { + modeName = "UNKNOWN"; + } + + System.out.println("=== COORDINATION MODE VERIFICATION ==="); + System.out.println("Mode: " + modeName); + System.out.println("Storage: " + storageClass); + System.out.println("======================================"); + + assertEquals("Expected Hazelcast storage", "HazelcastBuildMemoryStorage", storageClass); + assertEquals("Expected Hazelcast mode", "Hazelcast (Distributed)", modeName); + } + + /** + * Waits for a build to start (but not necessarily complete). + * + * @param project the project to check + * @param timeoutMs the timeout in milliseconds + * @throws InterruptedException if interrupted while sleeping + */ + private void waitForBuildToStart(FreeStyleProject project, long timeoutMs) throws InterruptedException { + long startTime = System.currentTimeMillis(); + while (project.getLastBuild() == null || !project.getLastBuild().isBuilding()) { + if (System.currentTimeMillis() - startTime >= timeoutMs) { + throw new RuntimeException("Timeout waiting for build to start!"); + } + Thread.sleep(SLEEP_HALF_SEC); + } + } + + /** + * Verifies that a build was interrupted with the expected cause by checking the build log. + * + * @param build the build to check + * @param expectedMessage the expected interruption message + * @throws Exception if unable to read build log + */ + private void assertInterruptionCause(FreeStyleBuild build, String expectedMessage) throws Exception { + String log = jenkins.getLog(build); + assertNotNull("Build log should not be null", log); + assertTrue("Build log should contain interruption message: " + expectedMessage, + log.contains(expectedMessage)); + } + + /** + * Tests that a new patchset cancels a running build of an old patchset in Hazelcast mode. + * This verifies the atomic TriggeredProcessor fix works correctly in distributed mode. + * + * @throws Exception if unexpected errors appear. + */ + @Test + @LocalData("common") + public void testNewPatchsetCancelsRunningBuildHazelcast() throws Exception { + // Create a job with a long-running build and cancellation policy + FreeStyleProject project = jenkins.createFreeStyleProject(); + project.getBuildersList().add(new SleepBuilder(SLEEP_10_SEC)); // 10 second build + + GerritTrigger trigger = Setup.createDefaultTrigger(project); + trigger.setGerritProjects(Collections.singletonList( + new GerritProject(CompareType.ANT, "**", + Collections.singletonList(new Branch(CompareType.ANT, "**")), + null, null, null, false))); + BuildCancellationPolicy policy = new BuildCancellationPolicy(false, false, false, false); + policy.setEnabled(true); + trigger.setBuildCancellationPolicy(policy); + project.addTrigger(trigger); + trigger.start(project, false); + + serverMock.waitForCommand(GERRIT_STREAM_EVENTS, SERVER_WAIT); + + // Trigger build with patchset 1 + PatchsetCreated patchset1 = Setup.createPatchsetCreated(); + patchset1.getChange().setId("Iabc123"); + patchset1.getChange().setNumber("1000"); + patchset1.getPatchSet().setNumber("1"); + + gerritServer.triggerEvent(patchset1); + + // Wait for build to start (not complete) + waitForBuildToStart(project, BUILD_TIMEOUT); + FreeStyleBuild build1 = project.getLastBuild(); + assertNotNull(build1); + assertTrue(build1.isBuilding()); + + // Trigger new patchset 2 (should cancel build1) + PatchsetCreated patchset2 = Setup.createPatchsetCreated(); + patchset2.getChange().setId("Iabc123"); + patchset2.getChange().setNumber("1000"); + patchset2.getPatchSet().setNumber("2"); + + gerritServer.triggerEvent(patchset2); + + // Wait for build1 to be aborted + jenkins.waitUntilNoActivity(); + + // Verify build1 was aborted + assertEquals("Build should be ABORTED (Hazelcast mode)", Result.ABORTED, build1.getResult()); + + // Verify it was aborted with the correct interruption cause + assertInterruptionCause(build1, Messages.AbortedByNewPatchSet()); + + // Verify build2 completed successfully + TestUtils.waitForBuilds(project, 2, BUILD_TIMEOUT); + FreeStyleBuild build2 = project.getLastBuild(); + assertNotNull(build2); + jenkins.assertBuildStatusSuccess(build2); + } + + /** + * Tests that abandoned patchsets cancel running builds in Hazelcast mode. + * + * @throws Exception if unexpected errors appear. + */ + @Test + @LocalData("common") + public void testAbandonedPatchsetCancelsRunningBuildHazelcast() throws Exception { + FreeStyleProject project = jenkins.createFreeStyleProject(); + project.getBuildersList().add(new SleepBuilder(SLEEP_10_SEC)); + + GerritTrigger trigger = Setup.createDefaultTrigger(project); + trigger.setGerritProjects(Collections.singletonList( + new GerritProject(CompareType.ANT, "**", + Collections.singletonList(new Branch(CompareType.ANT, "**")), + null, null, null, false))); + BuildCancellationPolicy policy = new BuildCancellationPolicy(false, false, false, true); + policy.setEnabled(true); + trigger.setBuildCancellationPolicy(policy); + project.addTrigger(trigger); + trigger.start(project, false); + + serverMock.waitForCommand(GERRIT_STREAM_EVENTS, SERVER_WAIT); + + // Trigger build with patchset 1 + PatchsetCreated patchset1 = Setup.createPatchsetCreated(); + patchset1.getChange().setId("Iabc123"); + patchset1.getChange().setNumber("1000"); + patchset1.getPatchSet().setNumber("1"); + + gerritServer.triggerEvent(patchset1); + + waitForBuildToStart(project, BUILD_TIMEOUT); + FreeStyleBuild build1 = project.getLastBuild(); + assertTrue(build1.isBuilding()); + + // Send abandoned event + ChangeAbandoned abandoned = Setup.createChangeAbandoned(); + abandoned.getChange().setId("Iabc123"); + abandoned.getChange().setNumber("1000"); + abandoned.getPatchSet().setNumber("1"); + + gerritServer.triggerEvent(abandoned); + + jenkins.waitUntilNoActivity(); + + // Verify build was aborted + assertEquals("Build should be ABORTED (Hazelcast mode)", Result.ABORTED, build1.getResult()); + + // Verify it was aborted with the correct interruption cause + assertInterruptionCause(build1, Messages.AbortedByAbandonedPatchset()); + } + + /** + * Tests that abortNewPatchsets policy cancels even newer patchsets in Hazelcast mode. + * + * @throws Exception if unexpected errors appear. + */ + @Test + @LocalData("common") + public void testAbortNewPatchsetsPolicyCancelsAnyPatchsetHazelcast() throws Exception { + FreeStyleProject project = jenkins.createFreeStyleProject(); + project.getBuildersList().add(new SleepBuilder(SLEEP_10_SEC)); + + GerritTrigger trigger = Setup.createDefaultTrigger(project); + trigger.setGerritProjects(Collections.singletonList( + new GerritProject(CompareType.ANT, "**", + Collections.singletonList(new Branch(CompareType.ANT, "**")), + null, null, null, false))); + BuildCancellationPolicy policy = new BuildCancellationPolicy(true, false, false, false); + policy.setEnabled(true); + trigger.setBuildCancellationPolicy(policy); + project.addTrigger(trigger); + trigger.start(project, false); + + serverMock.waitForCommand(GERRIT_STREAM_EVENTS, SERVER_WAIT); + + // Trigger build with patchset 2 + PatchsetCreated patchset2 = Setup.createPatchsetCreated(); + patchset2.getChange().setId("Iabc123"); + patchset2.getChange().setNumber("1000"); + patchset2.getPatchSet().setNumber("2"); + + gerritServer.triggerEvent(patchset2); + + waitForBuildToStart(project, BUILD_TIMEOUT); + FreeStyleBuild build1 = project.getLastBuild(); + assertTrue(build1.isBuilding()); + + // Trigger patchset 1 (older, but should still cancel build1 due to policy) + PatchsetCreated patchset1 = Setup.createPatchsetCreated(); + patchset1.getChange().setId("Iabc123"); + patchset1.getChange().setNumber("1000"); + patchset1.getPatchSet().setNumber("1"); + + gerritServer.triggerEvent(patchset1); + + jenkins.waitUntilNoActivity(); + + // Verify build1 was aborted + assertEquals("Build should be ABORTED (Hazelcast mode)", Result.ABORTED, build1.getResult()); + } +} diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationIntegrationTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationIntegrationTest.java index 40710a867..9a14cd383 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationIntegrationTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationIntegrationTest.java @@ -26,6 +26,7 @@ import com.sonyericsson.hudson.plugins.gerrit.trigger.GerritServer; import com.sonyericsson.hudson.plugins.gerrit.trigger.Messages; import com.sonyericsson.hudson.plugins.gerrit.trigger.PluginImpl; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastTestHelper; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.BuildCancellationPolicy; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.Branch; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.CompareType; @@ -60,7 +61,6 @@ * Integration tests for build cancellation feature that verify actual job cancellation * in Jenkins queue and running executors. * - * @author Ignacio Roncero <ironcero@cloudbees.com> */ public class BuildCancellationIntegrationTest { @@ -108,12 +108,15 @@ public void setUp() throws Exception { } /** - * Tears down the SSH server. + * Tears down the SSH server and clears Hazelcast state if active. * * @throws Exception if teardown fails */ @After public void tearDown() throws Exception { + // Clear Hazelcast state to prevent pollution between tests + HazelcastTestHelper.clearAllMaps(); + if (sshd != null) { sshd.stop(true); sshd = null; diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/replication/ReplicationQueueTaskDispatcherTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/replication/ReplicationQueueTaskDispatcherTest.java index c45c2f82e..783432af8 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/replication/ReplicationQueueTaskDispatcherTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/replication/ReplicationQueueTaskDispatcherTest.java @@ -57,7 +57,9 @@ import jenkins.model.TransientActionFactory; import org.junit.After; +import org.junit.AfterClass; import org.junit.Before; +import org.junit.BeforeClass; import org.junit.Test; import com.sonymobile.tools.gerrit.gerritevents.GerritHandler; @@ -71,6 +73,7 @@ import com.sonymobile.tools.gerrit.gerritevents.dto.events.PatchsetCreated; import com.sonymobile.tools.gerrit.gerritevents.dto.events.RefReplicated; import com.sonymobile.tools.gerrit.gerritevents.dto.events.RefUpdated; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastTestHelper; import com.sonyericsson.hudson.plugins.gerrit.trigger.events.ManualPatchsetCreated; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.GerritCause; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.GerritManualCause; @@ -93,8 +96,28 @@ public class ReplicationQueueTaskDispatcherTest { private static final int HOURSBEFORECHANGEMERGEDFORPATCHSET = -8; private static final int HOURBEFOREREPLICATIONCACHECREATED = -1; + private static final long ONE_SECOND_MS = 1000; + private static final long TWO_SECONDS_MS = 2000; private MockedStatic jenkinsMockedStatic; + /** + * Clear Hazelcast state before running this test class. + * Ensures clean state regardless of what tests ran before. + */ + @BeforeClass + public static void setUpClass() { + HazelcastTestHelper.clearAllMaps(); + } + + /** + * Clear Hazelcast state after running this test class. + * Prevents pollution of subsequent tests. + */ + @AfterClass + public static void tearDownClass() { + HazelcastTestHelper.clearAllMaps(); + } + /** * Create ReplicationQueueTaskDispatcher with a mocked GerritHandler. */ @@ -419,19 +442,32 @@ public void shouldBlockItemUntilIfPatchSetIsReplicatedToOneSlaveBeforeChangeMerg //to Gerrit for review). //For change merged event, a check on the event timestamp is done to make sure not to unblock the build for //a replica event that was fired before the current change merged event. - dispatcher.gerritEvent(Setup.createRefReplicatedEvent("someProject", "refs/heads/branch", "someGerritServer", - "slaveA", RefReplicated.SUCCEEDED_STATUS)); + + // Set explicit timestamps AFTER cache creation to avoid expiry issues. + // Timestamps must be relative to ensure old < changeMerged < new ordering. + long baseTime = System.currentTimeMillis() + ONE_SECOND_MS; // Start 1 second in the future + long oldReplicaTime = baseTime; + long changeMergedTime = baseTime + ONE_SECOND_MS; // 1 second after old replica + long newReplicaTime = baseTime + TWO_SECONDS_MS; // 2 seconds after old replica + + RefReplicated oldRefReplicated = Setup.createRefReplicatedEvent("someProject", "refs/heads/branch", + "someGerritServer", "slaveA", RefReplicated.SUCCEEDED_STATUS); + oldRefReplicated.setReceivedOn(oldReplicaTime); + dispatcher.gerritEvent(oldRefReplicated); ChangeMerged changeMerged = Setup.createChangeMerged("someGerritServer", "someProject", "refs/changes/1/1/1"); + changeMerged.setReceivedOn(changeMergedTime); Item item = createItem(changeMerged, new String[] {"slaveA"}); assertNotNull("Item should be blocked as the replica event happened before the change event", dispatcher.canRun(item)); //fire the replica event that will unblock the item - dispatcher.gerritEvent(Setup.createRefReplicatedEvent("someProject", "refs/heads/branch", "someGerritServer", - "slaveA", RefReplicated.SUCCEEDED_STATUS)); + RefReplicated newRefReplicated = Setup.createRefReplicatedEvent("someProject", "refs/heads/branch", + "someGerritServer", "slaveA", RefReplicated.SUCCEEDED_STATUS); + newRefReplicated.setReceivedOn(newReplicaTime); + dispatcher.gerritEvent(newRefReplicated); assertNull("Item should not be blocked anymore as a newer replica event was received", dispatcher.canRun(item)); @@ -638,14 +674,27 @@ public void shouldNotBlockItemWhenReplicationIsCompletedBeforeDispatcherIsCalled */ @Test public void shouldBlockItemUntilProperReplicationEventIsReceived() { + // Set explicit timestamps to avoid cache expiry issues. + // Old events should be before RefUpdated, new events after. + long baseTime = System.currentTimeMillis() + ONE_SECOND_MS; + long oldEventTime = baseTime; + long refUpdatedTime = baseTime + ONE_SECOND_MS; + long newEventTime = baseTime + TWO_SECONDS_MS; + //send replication events created before the actual event - dispatcher.gerritEvent(Setup.createRefReplicatedEvent("someProject", "refs/heads/master", "someGerritServer", - "slaveB", RefReplicated.SUCCEEDED_STATUS)); - dispatcher.gerritEvent(Setup.createRefReplicatedEvent("someProject", "refs/heads/master", "someGerritServer", - "slaveA", RefReplicated.SUCCEEDED_STATUS)); + RefReplicated oldRefRepB = Setup.createRefReplicatedEvent("someProject", "refs/heads/master", + "someGerritServer", "slaveB", RefReplicated.SUCCEEDED_STATUS); + oldRefRepB.setReceivedOn(oldEventTime); + dispatcher.gerritEvent(oldRefRepB); + + RefReplicated oldRefRepA = Setup.createRefReplicatedEvent("someProject", "refs/heads/master", + "someGerritServer", "slaveA", RefReplicated.SUCCEEDED_STATUS); + oldRefRepA.setReceivedOn(oldEventTime); + dispatcher.gerritEvent(oldRefRepA); RefUpdated refUpdated = Setup.createRefUpdated("someGerritServer", "someProject", "master"); + refUpdated.setReceivedOn(refUpdatedTime); Item item = createItem(refUpdated, new String[] {"slaveA", "slaveB"}); //item is blocked since the cached replication events are time stamped before the actual event @@ -657,10 +706,15 @@ public void shouldBlockItemUntilProperReplicationEventIsReceived() { assertTrue(cause.getShortDescription().contains("slaveB")); //send replication events created after the actual event - dispatcher.gerritEvent(Setup.createRefReplicatedEvent("someProject", "refs/heads/master", "someGerritServer", - "slaveB", RefReplicated.SUCCEEDED_STATUS)); - dispatcher.gerritEvent(Setup.createRefReplicatedEvent("someProject", "refs/heads/master", "someGerritServer", - "slaveA", RefReplicated.SUCCEEDED_STATUS)); + RefReplicated newRefRepB = Setup.createRefReplicatedEvent("someProject", "refs/heads/master", + "someGerritServer", "slaveB", RefReplicated.SUCCEEDED_STATUS); + newRefRepB.setReceivedOn(newEventTime); + dispatcher.gerritEvent(newRefRepB); + + RefReplicated newRefRepA = Setup.createRefReplicatedEvent("someProject", "refs/heads/master", + "someGerritServer", "slaveA", RefReplicated.SUCCEEDED_STATUS); + newRefRepA.setReceivedOn(newEventTime); + dispatcher.gerritEvent(newRefRepA); assertNull("Item should not be blocked", dispatcher.canRun(item)); verify(queueMock, times(1)).maintain(); diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/ParameterModeJenkinsTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/ParameterModeJenkinsTest.java index 1a7e3f2e6..c175cd04c 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/ParameterModeJenkinsTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/ParameterModeJenkinsTest.java @@ -25,6 +25,7 @@ import com.sonyericsson.hudson.plugins.gerrit.trigger.GerritServer; import com.sonyericsson.hudson.plugins.gerrit.trigger.PluginImpl; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastTestHelper; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.GerritTrigger; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.GerritTriggerParameters; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.Branch; @@ -57,6 +58,7 @@ import hudson.tasks.Builder; import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; +import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -113,6 +115,16 @@ public void setup() throws IOException { trigger.setEscapeQuotes(false); } + /** + * Clean up Hazelcast state after each test to prevent state pollution. + * This is critical for CommentAdded tests which use BuildMemory.isBuilding() + * to check for duplicate builds. + */ + @After + public void tearDown() { + HazelcastTestHelper.clearAllMaps(); + } + /** * Mock Gerrit server with a version. */ diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/SpecGerritTriggerHudsonTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/SpecGerritTriggerHudsonTest.java index e46b869d0..7f5f60ff9 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/SpecGerritTriggerHudsonTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/SpecGerritTriggerHudsonTest.java @@ -31,6 +31,7 @@ import com.sonymobile.tools.gerrit.gerritevents.dto.events.PatchsetCreated; import com.sonyericsson.hudson.plugins.gerrit.trigger.GerritServer; import com.sonyericsson.hudson.plugins.gerrit.trigger.PluginImpl; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastTestHelper; import com.sonyericsson.hudson.plugins.gerrit.trigger.config.Config; import com.sonyericsson.hudson.plugins.gerrit.trigger.events.ManualPatchsetCreated; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.GerritCause; @@ -134,6 +135,7 @@ public void setUp() throws Exception { public void tearDown() throws Exception { sshd.stop(true); sshd = null; + HazelcastTestHelper.clearAllMaps(); } /** diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/VoteSameTopicTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/VoteSameTopicTest.java index 27bb86ebe..5ab63da2a 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/VoteSameTopicTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/VoteSameTopicTest.java @@ -2,6 +2,7 @@ import com.sonyericsson.hudson.plugins.gerrit.trigger.GerritServer; import com.sonyericsson.hudson.plugins.gerrit.trigger.PluginImpl; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastTestHelper; import com.sonyericsson.hudson.plugins.gerrit.trigger.config.Config; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.GerritTrigger; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.Branch; @@ -120,6 +121,7 @@ public void setup() throws Exception { public void tearDown() throws Exception { sshd.stop(true); sshd = null; + HazelcastTestHelper.clearAllMaps(); } /** diff --git a/src/test/resources/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationHazelcastIntegrationTest/common/gerrit-trigger.xml b/src/test/resources/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationHazelcastIntegrationTest/common/gerrit-trigger.xml new file mode 100644 index 000000000..48b3ea51b --- /dev/null +++ b/src/test/resources/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationHazelcastIntegrationTest/common/gerrit-trigger.xml @@ -0,0 +1,27 @@ + + + + 127.0.0.1 + 29418 + jenkins + /tmp/jenkins-testkey + + 3 + gerrit review <CHANGE>,<PATCHSET> --message 'Build Successful' --verified <VERIFIED> + gerrit review <CHANGE>,<PATCHSET> --message 'Build Unstable' --verified <VERIFIED> + gerrit review <CHANGE>,<PATCHSET> --message 'Build Failed' --verified <VERIFIED> + gerrit review <CHANGE>,<PATCHSET> --message 'Build Started' --verified <VERIFIED> + http://localhost/ + 0 + 0 + 1 + 0 + -1 + 0 + -1 + 0 + true + 1 + 1 + + From 2c68b300fae98b19c4238091577d5bb64d1121cf Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Mon, 25 May 2026 09:25:02 +0200 Subject: [PATCH 05/87] Peer review comment #1: Event/Notification Claim Strategies Not Wired Up (Copilot) --- .../gerritnotifier/GerritNotifierFactory.java | 38 ++++++--- .../trigger/hudsontrigger/EventListener.java | 77 +++++++++++-------- 2 files changed, 73 insertions(+), 42 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java index 1cdfe42b6..460e64da9 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java @@ -33,12 +33,14 @@ import com.sonyericsson.hudson.plugins.gerrit.trigger.GerritServer; import com.sonyericsson.hudson.plugins.gerrit.trigger.PluginImpl; import com.sonyericsson.hudson.plugins.gerrit.trigger.config.IGerritHudsonTriggerConfig; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.CoordinationModeFactory; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.job.ssh.BuildCompletedCommandJob; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.job.rest.BuildCompletedRestCommandJob; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.job.ssh.BuildStartedCommandJob; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.job.rest.BuildStartedRestCommandJob; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildsStartedStats; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.NotificationClaimStrategy; import hudson.model.Run; import hudson.model.TaskListener; import org.slf4j.Logger; @@ -120,12 +122,19 @@ public void queueBuildCompleted(BuildMemory.MemoryImprint memoryImprint, TaskLis if (serverName != null) { IGerritHudsonTriggerConfig config = getConfig(serverName); if (config != null) { - if (config.isUseRestApi() - && memoryImprint.getEvent() instanceof ChangeBasedEvent) { - GerritSendCommandQueue.queue(new BuildCompletedRestCommandJob(config, memoryImprint, listener)); - } else { - GerritSendCommandQueue.queue(new BuildCompletedCommandJob(config, memoryImprint, listener)); - } + GerritTriggeredEvent event = memoryImprint.getEvent(); + + // Claim notification for sending (prevents duplicate notifications in HA/HS environments) + NotificationClaimStrategy notificationClaimStrategy = + CoordinationModeFactory.get().getClaimStrategy(); + notificationClaimStrategy.withClaim(event, () -> { + if (config.isUseRestApi() + && event instanceof ChangeBasedEvent) { + GerritSendCommandQueue.queue(new BuildCompletedRestCommandJob(config, memoryImprint, listener)); + } else { + GerritSendCommandQueue.queue(new BuildCompletedCommandJob(config, memoryImprint, listener)); + } + }); } else { logger.warn("Nothing queued since there is no configuration for serverName: {}", serverName); } @@ -196,12 +205,17 @@ public void queueBuildStarted(Run build, TaskListener listener, if (serverName != null) { IGerritHudsonTriggerConfig config = getConfig(serverName); if (config != null) { - if (config.isUseRestApi() && event instanceof ChangeBasedEvent) { - GerritSendCommandQueue.queue(new BuildStartedRestCommandJob(config, build, listener, - (ChangeBasedEvent)event, stats)); - } else { - GerritSendCommandQueue.queue(new BuildStartedCommandJob(config, build, listener, event, stats)); - } + // Claim notification for sending (prevents duplicate notifications in HA/HS environments) + NotificationClaimStrategy notificationClaimStrategy = + CoordinationModeFactory.get().getClaimStrategy(); + notificationClaimStrategy.withClaim(event, () -> { + if (config.isUseRestApi() && event instanceof ChangeBasedEvent) { + GerritSendCommandQueue.queue(new BuildStartedRestCommandJob(config, build, listener, + (ChangeBasedEvent)event, stats)); + } else { + GerritSendCommandQueue.queue(new BuildStartedCommandJob(config, build, listener, event, stats)); + } + }); } else { logger.warn("Nothing queued since there is no configuration for serverName: {}", serverName); } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/EventListener.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/EventListener.java index e719aec2e..cd21d6dd3 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/EventListener.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/EventListener.java @@ -24,11 +24,13 @@ package com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger; import com.sonyericsson.hudson.plugins.gerrit.trigger.config.IGerritHudsonTriggerConfig; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.CoordinationModeFactory; import com.sonyericsson.hudson.plugins.gerrit.trigger.events.ManualPatchsetCreated; import com.sonyericsson.hudson.plugins.gerrit.trigger.events.lifecycle.GerritEventLifecycle; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.ToGerritRunListener; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.actions.RetriggerAction; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.actions.RetriggerAllAction; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.EventClaimStrategy; import com.sonymobile.tools.gerrit.gerritevents.GerritEventListener; import com.sonymobile.tools.gerrit.gerritevents.dto.GerritEvent; import com.sonymobile.tools.gerrit.gerritevents.dto.events.ChangeBasedEvent; @@ -123,18 +125,23 @@ public void gerritEvent(GerritEvent event) { } if (event instanceof GerritTriggeredEvent) { GerritTriggeredEvent triggeredEvent = (GerritTriggeredEvent)event; - synchronized (this) { - if (t.isInteresting(triggeredEvent)) { - logger.trace("The event is interesting."); - abortBuild(t, triggeredEvent); - if (t.isOnlyAbortRunningBuild(triggeredEvent)) { - logger.trace("Just aborting build based on event not scheduling new one."); - return; + + // Claim event for processing (prevents duplicate builds in HA/HS environments) + EventClaimStrategy eventClaimStrategy = CoordinationModeFactory.get().getEventClaimStrategy(); + eventClaimStrategy.withClaim(triggeredEvent, () -> { + synchronized (this) { + if (t.isInteresting(triggeredEvent)) { + logger.trace("The event is interesting."); + abortBuild(t, triggeredEvent); + if (t.isOnlyAbortRunningBuild(triggeredEvent)) { + logger.trace("Just aborting build based on event not scheduling new one."); + return; + } + notifyOnTriggered(t, triggeredEvent); + schedule(t, new GerritCause(triggeredEvent, t.isSilentMode()), triggeredEvent); } - notifyOnTriggered(t, triggeredEvent); - schedule(t, new GerritCause(triggeredEvent, t.isSilentMode()), triggeredEvent); } - } + }); } } @@ -163,18 +170,23 @@ public void gerritEvent(ManualPatchsetCreated event) { // to just return now without processing the event. return; } - synchronized (this) { - if (t.isInteresting(event)) { - logger.trace("The event is interesting."); - abortBuild(t, event); - if (t.isOnlyAbortRunningBuild(event)) { - logger.trace("Just aborting build based on event not scheduling new one."); - return; + + // Claim event for processing (prevents duplicate builds in HA/HS environments) + EventClaimStrategy eventClaimStrategy = CoordinationModeFactory.get().getEventClaimStrategy(); + eventClaimStrategy.withClaim(event, () -> { + synchronized (this) { + if (t.isInteresting(event)) { + logger.trace("The event is interesting."); + abortBuild(t, event); + if (t.isOnlyAbortRunningBuild(event)) { + logger.trace("Just aborting build based on event not scheduling new one."); + return; + } + notifyOnTriggered(t, event); + schedule(t, new GerritManualCause(event, t.isSilentMode()), event); } - notifyOnTriggered(t, event); - schedule(t, new GerritManualCause(event, t.isSilentMode()), event); } - } + }); } /** @@ -209,18 +221,23 @@ public void gerritEvent(CommentAdded event) { // to just return now without processing the event. return; } - synchronized (this) { - if (t.isInteresting(event) && t.commentAddedMatch(event)) { - logger.trace("The event is interesting."); - abortBuild(t, event); - if (t.isOnlyAbortRunningBuild(event)) { - logger.trace("Just aborting build based on event not scheduling new one."); - return; + + // Claim event for processing (prevents duplicate builds in HA/HS environments) + EventClaimStrategy eventClaimStrategy = CoordinationModeFactory.get().getEventClaimStrategy(); + eventClaimStrategy.withClaim(event, () -> { + synchronized (this) { + if (t.isInteresting(event) && t.commentAddedMatch(event)) { + logger.trace("The event is interesting."); + abortBuild(t, event); + if (t.isOnlyAbortRunningBuild(event)) { + logger.trace("Just aborting build based on event not scheduling new one."); + return; + } + notifyOnTriggered(t, event); + schedule(t, new GerritCause(event, t.isSilentMode()), event); } - notifyOnTriggered(t, event); - schedule(t, new GerritCause(event, t.isSilentMode()), event); } - } + }); } /** From d30c6654210f8f2a093d2853c5483d719531c1d0 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Mon, 25 May 2026 10:13:55 +0200 Subject: [PATCH 06/87] Peer review comment #2: EventIdentifier Uses hashCode() - Not Stable Across JVMs (Copilot #4, Robert #12) --- .../hazelcast/EventIdentifier.java | 102 ++++++++++++++++-- 1 file changed, 92 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java index bdfd2b87d..c8f0830b1 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java @@ -55,6 +55,21 @@ public final class EventIdentifier { */ private static final int SHORT_REVISION_LENGTH = 8; + /** + * Initial prime number for hash computation (standard Java hashCode practice). + */ + private static final int HASH_INITIAL_PRIME = 17; + + /** + * Multiplier prime number for hash computation (standard Java hashCode practice). + */ + private static final int HASH_MULTIPLIER_PRIME = 31; + + /** + * Number of bits to shift for long-to-int hash conversion. + */ + private static final int HASH_LONG_SHIFT_BITS = 32; + /** * Private constructor to prevent instantiation. */ @@ -86,9 +101,12 @@ public static String generateEventId(GerritTriggeredEvent event) { /** * Generates ID for change-based events (patchset-created, comment-added, etc.). + *

+ * Prefers {@code changeId} over deprecated {@code change.getNumber()}. + * Includes branch since changeId is not unique across branches. * * @param event the change-based event - * @return event ID in format: change-{project}-{number}-{patchset}-{type}-{timestamp} + * @return event ID in format: change-{project}-{changeId}-{branch}-{patchset}-{type}-{timestamp} */ private static String generateChangeBasedEventId(ChangeBasedEvent event) { Change change = event.getChange(); @@ -102,11 +120,28 @@ private static String generateChangeBasedEventId(ChangeBasedEvent event) { // Fall back to receivedOn if eventCreatedOn is not available long timestamp = getEventTimestamp(event); - // Format: change-{project}-{number}-{patchset}-{type}-{timestamp} - // Project is included because change numbers are only unique within a project - return String.format("change-%s-%s-%s-%s-%d", + // Prefer changeId (I...) over deprecated change number + // Note: changeId is not unique across branches, so branch must be included + String changeIdentifier; + if (change.getId() != null && !change.getId().isEmpty()) { + // Use Change-Id (format: I1234567890abcdef...) + changeIdentifier = sanitize(change.getId()); + } else { + // Fallback to change number if changeId not available (old Gerrit versions) + changeIdentifier = "num-" + change.getNumber(); + } + + // Include branch for uniqueness (changeId can be reused across branches) + String branch = "unknown"; + if (change.getBranch() != null && !change.getBranch().isEmpty()) { + branch = sanitize(change.getBranch()); + } + + // Format: change-{project}-{changeId}-{branch}-{patchset}-{type}-{timestamp} + return String.format("change-%s-%s-%s-%s-%s-%d", sanitize(change.getProject()), - change.getNumber(), + changeIdentifier, + branch, patchSet.getNumber(), sanitizeEventType(event.getEventType().getTypeValue()), timestamp); @@ -149,21 +184,68 @@ private static String generateRefUpdatedEventId(RefUpdated event) { /** * Generates fallback ID for events that don't match known patterns. + *

+ * Uses only deterministic fields to ensure the same event produces the same ID + * across all replicas. Specifically avoids {@code hashCode()} which is not stable + * across JVMs. * * @param event the event - * @return event ID in format: event-{type}-{timestamp}-{hash} + * @return event ID in format: event-{type}-{server}-{timestamp}-{deterministicHash} */ private static String generateFallbackEventId(GerritTriggeredEvent event) { // Use server-side timestamp (eventCreatedOn) for consistency across replicas // Fall back to receivedOn if eventCreatedOn is not available long timestamp = getEventTimestamp(event); - // Format: event--- - // Hash provides uniqueness when timestamp alone isn't sufficient - return String.format("event-%s-%d-%08x", + // Get server name for additional uniqueness + String serverName = "unknown"; + if (event.getProvider() != null && event.getProvider().getName() != null) { + serverName = sanitize(event.getProvider().getName()); + } + + // Create deterministic hash from event fields (not object hashCode!) + int deterministicHash = computeDeterministicHash(event); + + // Format: event---- + // All components are deterministic across replicas + return String.format("event-%s-%s-%d-%08x", sanitizeEventType(event.getEventType().getTypeValue()), + serverName, timestamp, - event.hashCode()); + deterministicHash); + } + + /** + * Computes a deterministic hash from event fields. + *

+ * This hash is stable across JVMs because it's computed from the event's actual + * field values, not from the object's identity or {@code hashCode()}. + *

+ * Uses the same fields that would typically be in a well-implemented {@code hashCode()}: + * event type and timestamp. The provider name is included in the event ID directly, + * so doesn't need to be part of the hash. + * + * @param event the event + * @return deterministic hash value + */ + private static int computeDeterministicHash(GerritTriggeredEvent event) { + int result = HASH_INITIAL_PRIME; // Start with prime number + + // Use event type (always available) + if (event.getEventType() != null && event.getEventType().getTypeValue() != null) { + result = HASH_MULTIPLIER_PRIME * result + event.getEventType().getTypeValue().hashCode(); + } + + // Use timestamp (already deterministic across replicas) + long timestamp = getEventTimestamp(event); + result = HASH_MULTIPLIER_PRIME * result + (int)(timestamp ^ (timestamp >>> HASH_LONG_SHIFT_BITS)); + + // Use server name if available + if (event.getProvider() != null && event.getProvider().getName() != null) { + result = HASH_MULTIPLIER_PRIME * result + event.getProvider().getName().hashCode(); + } + + return result; } /** From daa728c6357eb5f160ceec4bca988efdac4b8988 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Mon, 25 May 2026 11:27:36 +0200 Subject: [PATCH 07/87] Peer review comment #3: Race Conditions in retriggered() and removeProject() --- .../HazelcastBuildMemoryStorage.java | 76 +++------ .../hazelcast/RemoveProjectProcessor.java | 89 ++++++++++ .../hazelcast/RetriggeredProcessor.java | 155 ++++++++++++++++++ 3 files changed, 263 insertions(+), 57 deletions(-) create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/RemoveProjectProcessor.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/RetriggeredProcessor.java diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 97d18be06..b67bc7143 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -298,51 +298,12 @@ public synchronized void retriggered(@NonNull GerritTriggeredEvent event, @NonNu } BuildMemoryKey key = new BuildMemoryKey(event); - MemoryImprintData data = map.get(key); - - if (data == null) { - // Create new memory imprint data - data = new MemoryImprintData(); - data.setEventJson(serializeEvent(event)); - - if (otherBuilds != null) { - // Populate with old build info - for (Run build : otherBuilds) { - EntryData entryData = new EntryData(); - entryData.setProjectFullName(build.getParent().getFullName()); - entryData.setBuildId(build.getId()); - entryData.setBuildCompleted(!build.isBuilding()); - data.addEntry(entryData); - } - } - } - - // Reset the retriggered project (clear build info) String projectFullName = project.getFullName(); - boolean found = false; - - if (data.getEntries() != null) { - for (EntryData entry : data.getEntries()) { - if (projectFullName.equals(entry.getProjectFullName())) { - // Reset this entry - entry.setBuildId(null); - entry.setBuildCompleted(false); - entry.setStartedTimestamp(null); - entry.setCompletedTimestamp(null); - found = true; - break; - } - } - } + String eventJson = serializeEvent(event); - if (!found) { - // Add new entry for retriggered project - EntryData entryData = new EntryData(); - entryData.setProjectFullName(projectFullName); - data.addEntry(entryData); - } + // ATOMIC OPERATION - Executes on partition owner, prevents race conditions + map.executeOnKey(key, new RetriggeredProcessor(projectFullName, eventJson, otherBuilds)); - map.put(key, data); logger.trace("Retriggered event stored in distributed memory: {}", key); } @@ -404,21 +365,22 @@ public synchronized void removeProject(@NonNull Job project) { return; } - // Iterate over all entries in distributed memory - for (Map.Entry mapEntry : map.entrySet()) { - MemoryImprintData data = mapEntry.getValue(); - if (data.getEntries() != null) { - // Remove entries matching this project - boolean removed = data.getEntries().removeIf( - entry -> projectFullName.equals(entry.getProjectFullName()) - ); - - // If we removed anything, update the map - if (removed) { - map.put(mapEntry.getKey(), data); - logger.trace("Removed project {} from distributed memory entry: {}", - projectFullName, mapEntry.getKey()); - } + // ATOMIC OPERATION - Process each entry atomically to prevent race conditions + // Collect keys first to avoid ConcurrentModificationException + java.util.Set keys = new java.util.HashSet<>(map.keySet()); + + for (BuildMemoryKey key : keys) { + // Execute processor atomically on partition owner + Boolean shouldDelete = map.executeOnKey(key, new RemoveProjectProcessor(projectFullName)); + + if (shouldDelete != null && shouldDelete) { + // MemoryImprintData is now empty - delete the map entry + map.delete(key); + logger.trace("Removed empty entry for project {} from distributed memory: {}", + projectFullName, key); + } else if (shouldDelete != null) { + logger.trace("Removed project {} from distributed memory entry: {}", + projectFullName, key); } } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/RemoveProjectProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/RemoveProjectProcessor.java new file mode 100644 index 000000000..06f4c1d48 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/RemoveProjectProcessor.java @@ -0,0 +1,89 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.map.EntryProcessor; + +import java.io.Serializable; +import java.util.Map; + +/** + * Hazelcast EntryProcessor for atomic project removal operation. + *

+ * Removes all EntryData entries matching the specified project name from + * a single MemoryImprintData entry. If after removal the MemoryImprintData + * becomes empty (no entries left), it signals for map entry deletion by + * returning {@code true}. + *

+ * This processor ensures atomicity - even if multiple replicas attempt + * concurrent removal operations, the updates won't overwrite each other. + * + * @see HazelcastBuildMemoryStorage#removeProject + */ +public class RemoveProjectProcessor implements EntryProcessor, + Serializable { + + private static final long serialVersionUID = 1L; + + private final String projectFullName; + + /** + * Constructor for RemoveProjectProcessor. + * + * @param projectFullName the full name of the project to remove + */ + public RemoveProjectProcessor(String projectFullName) { + this.projectFullName = projectFullName; + } + + @Override + public Boolean process(Map.Entry entry) { + MemoryImprintData data = entry.getValue(); + + if (data == null || data.getEntries() == null) { + // No data or no entries - nothing to remove + return false; + } + + // Remove matching entries + boolean removed = data.getEntries().removeIf(entryData -> + projectFullName.equals(entryData.getProjectFullName()) + ); + + if (removed) { + // Check if MemoryImprintData is now empty + if (data.getEntries() == null || data.getEntries().isEmpty()) { + // Signal that this map entry should be deleted + return true; + } else { + // Update the entry with modified data + entry.setValue(data); + return false; + } + } + + // Nothing was removed + return false; + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/RetriggeredProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/RetriggeredProcessor.java new file mode 100644 index 000000000..4d554d2fb --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/RetriggeredProcessor.java @@ -0,0 +1,155 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.map.EntryProcessor; +import hudson.model.Run; + +import java.io.Serializable; +import java.util.List; +import java.util.Map; + +/** + * Hazelcast EntryProcessor for atomic retriggered operation. + *

+ * Handles the case where a job is retriggered for the same event. + * Resets the retriggered project's build information while preserving + * other builds from the previous trigger context. + *

+ * This processor ensures atomicity - even if multiple replicas attempt + * concurrent retriggered operations, the updates won't overwrite each other. + * + * @see HazelcastBuildMemoryStorage#retriggered + */ +public class RetriggeredProcessor implements EntryProcessor, Serializable { + + private static final long serialVersionUID = 1L; + + private final String projectFullName; + private final String eventJson; + private final List otherBuildsList; + + /** + * Constructor for RetriggeredProcessor. + * + * @param projectFullName the full name of the project being retriggered + * @param eventJson JSON serialization of the event + * @param otherBuilds list of other builds from previous trigger context (can be null) + */ + public RetriggeredProcessor(String projectFullName, String eventJson, List otherBuilds) { + this.projectFullName = projectFullName; + this.eventJson = eventJson; + + // Convert Run objects to serializable BuildInfo + // (Run objects are not serializable, so we extract the needed data) + if (otherBuilds != null && !otherBuilds.isEmpty()) { + this.otherBuildsList = new java.util.ArrayList<>(otherBuilds.size()); + for (Run build : otherBuilds) { + this.otherBuildsList.add(new BuildInfo( + build.getParent().getFullName(), + build.getId(), + !build.isBuilding() + )); + } + } else { + this.otherBuildsList = null; + } + } + + @Override + public Void process(Map.Entry entry) { + MemoryImprintData data = entry.getValue(); + + if (data == null) { + // Create new memory imprint data + data = new MemoryImprintData(); + data.setEventJson(eventJson); + + if (otherBuildsList != null) { + // Populate with old build info + for (BuildInfo buildInfo : otherBuildsList) { + EntryData entryData = new EntryData(); + entryData.setProjectFullName(buildInfo.projectFullName); + entryData.setBuildId(buildInfo.buildId); + entryData.setBuildCompleted(buildInfo.completed); + data.addEntry(entryData); + } + } + } + + // Reset the retriggered project (clear build info) + boolean found = false; + + if (data.getEntries() != null) { + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + // Reset this entry + entryData.setBuildId(null); + entryData.setBuildCompleted(false); + entryData.setStartedTimestamp(null); + entryData.setCompletedTimestamp(null); + found = true; + break; + } + } + } + + if (!found) { + // Add new entry for retriggered project + EntryData entryData = new EntryData(); + entryData.setProjectFullName(projectFullName); + data.addEntry(entryData); + } + + // Update the entry value + entry.setValue(data); + + return null; + } + + /** + * Serializable wrapper for Build information. + * Used to transfer build data across Hazelcast cluster without serializing Run objects. + */ + private static class BuildInfo implements Serializable { + private static final long serialVersionUID = 1L; + + private final String projectFullName; + private final String buildId; + private final boolean completed; + + /** + * Constructor for BuildInfo. + * + * @param projectFullName the full name of the project + * @param buildId the build ID + * @param completed true if the build is completed + */ + BuildInfo(String projectFullName, String buildId, boolean completed) { + this.projectFullName = projectFullName; + this.buildId = buildId; + this.completed = completed; + } + } +} From 3cc4a557d4c43a719b42741f12a355f25a86d992 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Mon, 25 May 2026 11:58:08 +0200 Subject: [PATCH 08/87] Peer review comment #3: getDistributedMemory() Thread Safety --- .../HazelcastBuildMemoryStorage.java | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index b67bc7143..5c88e1954 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -82,23 +82,33 @@ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { /** * Distributed mode storage (coordination mode). * Lazy-initialized when first accessed. + * Marked volatile for thread-safe double-checked locking pattern. */ - private transient IMap distributedMemory = null; + private transient volatile IMap distributedMemory = null; /** - * Gets or initializes the distributed memory map. + * Gets or initializes the distributed memory map using thread-safe double-checked locking. + *

+ * Uses volatile field and synchronized block to ensure only one thread initializes + * the map while avoiding synchronization overhead on subsequent accesses. * * @return distributed memory map, or null if Hazelcast unavailable */ private IMap getDistributedMemory() { + // First check (no locking) - fast path for already-initialized case if (distributedMemory == null) { - HazelcastInstance hz = HazelcastInstanceProvider.getInstance(); - if (hz != null) { - distributedMemory = hz.getMap(MAP_NAME); - logger.debug("Initialized distributed BuildMemory map: {} (size: {})", - MAP_NAME, distributedMemory.size()); - } else { - logger.warn("Hazelcast unavailable, distributed memory not available"); + synchronized (this) { + // Second check (with locking) - ensures only one thread initializes + if (distributedMemory == null) { + HazelcastInstance hz = HazelcastInstanceProvider.getInstance(); + if (hz != null) { + distributedMemory = hz.getMap(MAP_NAME); + logger.debug("Initialized distributed BuildMemory map: {} (size: {})", + MAP_NAME, distributedMemory.size()); + } else { + logger.warn("Hazelcast unavailable, distributed memory not available"); + } + } } } return distributedMemory; From 5ac64ad575fd417a375c9879bd5c75d8b268cc70 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Mon, 25 May 2026 12:25:35 +0200 Subject: [PATCH 09/87] Peer review comment #5: TCP Member Configuration Bug (Copilot #2) --- .../coordination/hazelcast/HazelcastConfig.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java index ef7c9ecb5..9e27f052c 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java @@ -250,9 +250,18 @@ private static void configureTcpDiscovery(JoinConfig joinConfig) { logger.info("Configuring TCP/IP discovery with members: {}", tcpMembers); - joinConfig.getTcpIpConfig() - .setEnabled(true) - .addMember(tcpMembers); + // Split comma-separated member list and add each member individually + String[] members = tcpMembers.split(","); + com.hazelcast.config.TcpIpConfig tcpIpConfig = joinConfig.getTcpIpConfig(); + tcpIpConfig.setEnabled(true); + + for (String member : members) { + String trimmedMember = member.trim(); + if (!trimmedMember.isEmpty()) { + tcpIpConfig.addMember(trimmedMember); + logger.debug("Added TCP member: {}", trimmedMember); + } + } // Disable other discovery methods joinConfig.getKubernetesConfig().setEnabled(false); From d041dbe08314e0ba9a1ebb609baf50b987af3d4c Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Mon, 25 May 2026 13:11:04 +0200 Subject: [PATCH 10/87] Peer review comment #6: TCP Fallback Port Mismatch --- .../coordination/hazelcast/HazelcastConfig.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java index 9e27f052c..dda12d944 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java @@ -189,14 +189,14 @@ private static void configureNetwork(Config config) { } else if ("kubernetes".equalsIgnoreCase(discoveryMode) || isKubernetesEnvironment()) { configureKubernetesDiscovery(joinConfig, port); } else if ("tcp".equalsIgnoreCase(discoveryMode) || hasTcpMembersConfigured()) { - configureTcpDiscovery(joinConfig); + configureTcpDiscovery(joinConfig, port); } else { // Fallback: Try Kubernetes, then TCP logger.info("Auto-detecting discovery mechanism..."); if (isKubernetesEnvironment()) { configureKubernetesDiscovery(joinConfig, port); } else { - configureTcpDiscovery(joinConfig); + configureTcpDiscovery(joinConfig, port); } } @@ -235,17 +235,18 @@ private static void configureKubernetesDiscovery(JoinConfig joinConfig, int port * Configures TCP/IP discovery with static member list. * * @param joinConfig the join configuration + * @param port the Hazelcast port (used in fallback and example messages) */ - private static void configureTcpDiscovery(JoinConfig joinConfig) { + private static void configureTcpDiscovery(JoinConfig joinConfig, int port) { String tcpMembers = System.getProperty(TCP_MEMBERS_PROPERTY, ""); if (tcpMembers.isEmpty()) { logger.warn("TCP discovery mode selected but no members configured. " + "Set {} system property.", TCP_MEMBERS_PROPERTY); - logger.warn("Example: -D{}=replica-0.jenkins:5701,replica-1.jenkins:5701", - TCP_MEMBERS_PROPERTY); + logger.warn("Example: -D{}=replica-0.jenkins:{},replica-1.jenkins:{}", + TCP_MEMBERS_PROPERTY, port, port); // Use localhost as fallback for single-instance testing - tcpMembers = "localhost:5701"; + tcpMembers = "localhost:" + port; } logger.info("Configuring TCP/IP discovery with members: {}", tcpMembers); From d9ff616f813e226c9cec26239fc9671b93ad0f47 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Mon, 25 May 2026 13:52:12 +0200 Subject: [PATCH 11/87] Peer review comment #7: MemoryImprintData vs MemoryImprint Design Question --- .../coordination/hazelcast/EntryData.java | 20 ++++++-- .../HazelcastBuildMemoryStorage.java | 25 +++++++++ .../hazelcast/MemoryImprintData.java | 51 +++++++++++++++++-- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java index e91ab55d6..60606b578 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java @@ -26,11 +26,25 @@ import edu.umd.cs.findbugs.annotations.CheckForNull; /** - * Serializable data for BuildMemory Entry. + * Serializable data transfer object for BuildMemory Entry in Hazelcast distributed storage. *

- * Stores job and build information without Jenkins object references. - * Uses Compact Serialization for cross-JVM compatibility. + * This class is the serialization-friendly counterpart to + * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory.MemoryImprint.Entry}. + * It stores the same information but uses only primitives and strings instead of Jenkins object references. + *

+ * Stored Data: + *

    + *
  • projectFullName: String identifier for the job (instead of {@link hudson.model.Job} reference)
  • + *
  • buildId: String identifier for the build (instead of {@link hudson.model.Run} reference)
  • + *
  • Build state: completion status, cancellation flags, timestamps
  • + *
  • Feedback data: custom URLs and unsuccessful messages for Gerrit comments
  • + *
+ *

+ * Uses Hazelcast Compact Serialization for cross-JVM compatibility in sidecar deployments. * + * @see MemoryImprintData + * @see HazelcastBuildMemoryStorage#reconstructMemoryImprint + * @see EntryDataSerializer */ public class EntryData { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 5c88e1954..ae042862a 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -59,8 +59,33 @@ *

  • Coordination mode is set to 'hazelcast' via system property
  • *
  • Hazelcast instance is available and running
  • * + *

    + * Serialization Strategy (MemoryImprint ↔ MemoryImprintData): + *

    + * This class handles conversion between the API type + * ({@link com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory.MemoryImprint}) + * and the serialization type ({@link MemoryImprintData}): + *

      + *
    • Write Path: Business logic → MemoryImprint → (convert) → MemoryImprintData → Hazelcast IMap
    • + *
    • Read Path: Hazelcast IMap → MemoryImprintData → (reconstruct) → MemoryImprint → Business logic
    • + *
    + *

    + * Conversion Details: + *

      + *
    • Event Serialization: {@link #serializeEvent} converts GerritTriggeredEvent to JSON + * using {@link PolymorphicEventTypeAdapter} for type preservation
    • + *
    • Entry Data Extraction: EntryProcessors extract string identifiers (project full names, + * build IDs) from Jenkins objects before storage
    • + *
    • Reconstruction: {@link #reconstructMemoryImprint} deserializes JSON to events and + * looks up Jenkins objects via {@link jenkins.model.Jenkins#getItemByFullName}
    • + *
    + *

    + * This conversion happens only at storage boundaries, keeping the rest of the plugin + * unaware of serialization concerns. * * @see HazelcastCoordinationProvider + * @see MemoryImprintData + * @see PolymorphicEventTypeAdapter */ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintData.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintData.java index baf794d06..2ac504516 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintData.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintData.java @@ -27,11 +27,56 @@ import java.util.List; /** - * Serializable data for MemoryImprint to store in Hazelcast. + * Serializable data transfer object for BuildMemory storage in Hazelcast distributed maps. *

    - * Contains simplified Entry data without complex object references. - * Uses Compact Serialization for cross-JVM compatibility in sidecar deployments. + * Design Rationale - Why separate from MemoryImprint? + *

    + * This class exists alongside + * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory.MemoryImprint} + * to separate API concerns from serialization concerns: + *

      + *
    • MemoryImprint: The main API class used by business logic throughout the plugin. + * Contains Jenkins objects ({@link hudson.model.Job}, {@link hudson.model.Run}, + * {@link com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent}) + * which are not serializable or cross-JVM compatible.
    • + *
    • MemoryImprintData: Serialization-optimized data structure for Hazelcast storage. + * Contains only primitives and strings (event JSON, project full names, build IDs) + * which can be safely serialized across JVM boundaries.
    • + *
    + *

    + * Key Benefits of This Design: + *

      + *
    • Cross-JVM Compatibility: Uses Hazelcast Compact Serialization which works across + * different JVMs and classloaders (critical for sidecar deployment scenarios)
    • + *
    • API Stability: MemoryImprint API remains unchanged, preserving backward compatibility + * with existing code throughout the plugin
    • + *
    • No Object References: Avoids serializing Jenkins objects which may not exist on + * remote replicas or may change between serialization/deserialization
    • + *
    • Explicit Conversion: Forces explicit conversion at storage boundaries, making + * the serialization strategy visible and testable
    • + *
    + *

    + * Conversion Strategy: + *

      + *
    • Storage: {@link HazelcastBuildMemoryStorage} converts MemoryImprint to MemoryImprintData + * by serializing events to JSON and extracting string identifiers (project names, build IDs)
    • + *
    • Retrieval: {@link HazelcastBuildMemoryStorage#reconstructMemoryImprint} converts + * MemoryImprintData back to MemoryImprint by deserializing events and looking up Jenkins + * objects via {@link jenkins.model.Jenkins#getItemByFullName}
    • + *
    + *

    + * Alternative Considered and Rejected: + * Making MemoryImprint directly serializable was rejected because: + *

      + *
    • Jenkins objects (Job, Run) are not reliably serializable across replicas
    • + *
    • GerritTriggeredEvent requires custom polymorphic serialization
    • + *
    • Would break in sidecar scenarios where classloaders differ
    • + *
    • Would tightly couple the API to Hazelcast serialization details
    • + *
    * + * @see HazelcastBuildMemoryStorage + * @see com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory.MemoryImprint + * @see MemoryImprintDataSerializer */ public class MemoryImprintData { From 0cf1663d1bf249accfc08abcb76dddc9ad10c906 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Mon, 25 May 2026 15:02:50 +0200 Subject: [PATCH 12/87] Peer review comment #8: Timestamp Setting Order Issues --- .../trigger/coordination/hazelcast/EntryData.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java index 60606b578..0dd719f7a 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java @@ -110,14 +110,14 @@ public String getBuildId() { /** * Sets the build ID. + *

    + * Note: Does not automatically set startedTimestamp. Callers should explicitly set + * the timestamp using {@link #setStartedTimestamp(Long)} when appropriate. * * @param buildId build ID */ public void setBuildId(String buildId) { this.buildId = buildId; - if (buildId != null && startedTimestamp == null) { - this.startedTimestamp = System.currentTimeMillis(); - } } /** @@ -131,14 +131,14 @@ public boolean isBuildCompleted() { /** * Sets build completed status. + *

    + * Note: Does not automatically set completedTimestamp. Callers should explicitly set + * the timestamp using {@link #setCompletedTimestamp(Long)} when appropriate. * * @param buildCompleted completed status */ public void setBuildCompleted(boolean buildCompleted) { this.buildCompleted = buildCompleted; - if (buildCompleted && completedTimestamp == null) { - this.completedTimestamp = System.currentTimeMillis(); - } } /** From d18d860cf3ee9c6074baf0bd514947bfc214388c Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Mon, 25 May 2026 17:43:25 +0200 Subject: [PATCH 13/87] Peer review comment #10: Runtime Exception Handling in executeOnKey Calls --- .../HazelcastBuildMemoryStorage.java | 134 ++++++++++++------ 1 file changed, 89 insertions(+), 45 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index ae042862a..1f933c44b 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -272,12 +272,17 @@ public synchronized void triggered(@NonNull GerritTriggeredEvent event, @NonNull // This is critical when multiple projects are triggered by the same event simultaneously. // Without atomic operations, concurrent threads can overwrite each other's entries, // causing some project entries to be lost from BuildMemory (which breaks cancellation logic). - Boolean wasNew = map.executeOnKey(key, new TriggeredProcessor(projectFullName, eventJson)); + try { + Boolean wasNew = map.executeOnKey(key, new TriggeredProcessor(projectFullName, eventJson)); - if (wasNew) { - logger.trace("Triggered event stored in distributed memory: {} for project: {}", key, projectFullName); - } else { - logger.trace("Project {} already triggered for event: {}", projectFullName, key); + if (wasNew) { + logger.trace("Triggered event stored in distributed memory: {} for project: {}", key, projectFullName); + } else { + logger.trace("Project {} already triggered for event: {}", projectFullName, key); + } + } catch (Exception e) { + logger.error("Failed to store triggered event in distributed memory for project: {} event: {}", + projectFullName, key, e); } } @@ -294,12 +299,17 @@ public synchronized void started(@NonNull GerritTriggeredEvent event, @NonNull R String buildId = build.getId(); // ATOMIC OPERATION - Executes on partition owner, prevents race conditions - Boolean found = map.executeOnKey(key, new BuildStartedProcessor(projectFullName, buildId)); + try { + Boolean found = map.executeOnKey(key, new BuildStartedProcessor(projectFullName, buildId)); - if (!found) { - logger.warn("Build started without being registered first (distributed mode)."); + if (!found) { + logger.warn("Build started without being registered first (distributed mode)."); + } + logger.trace("Build started event stored in distributed memory: {}", key); + } catch (Exception e) { + logger.error("Failed to mark build started in distributed memory: project={}, build={}, event={}", + projectFullName, buildId, key, e); } - logger.trace("Build started event stored in distributed memory: {}", key); } @Override @@ -315,12 +325,17 @@ public synchronized void completed(@NonNull GerritTriggeredEvent event, @NonNull String buildId = build.getId(); // ATOMIC OPERATION - Executes on partition owner, prevents race conditions - Boolean found = map.executeOnKey(key, new BuildCompletedProcessor(projectFullName, buildId)); + try { + Boolean found = map.executeOnKey(key, new BuildCompletedProcessor(projectFullName, buildId)); - if (!found) { - logger.debug("Build completed without being registered first (distributed mode)."); + if (!found) { + logger.debug("Build completed without being registered first (distributed mode)."); + } + logger.trace("Build completed event stored in distributed memory: {}", key); + } catch (Exception e) { + logger.error("Failed to mark build completed in distributed memory: project={}, build={}, event={}", + projectFullName, buildId, key, e); } - logger.trace("Build completed event stored in distributed memory: {}", key); } @Override @@ -337,9 +352,13 @@ public synchronized void retriggered(@NonNull GerritTriggeredEvent event, @NonNu String eventJson = serializeEvent(event); // ATOMIC OPERATION - Executes on partition owner, prevents race conditions - map.executeOnKey(key, new RetriggeredProcessor(projectFullName, eventJson, otherBuilds)); - - logger.trace("Retriggered event stored in distributed memory: {}", key); + try { + map.executeOnKey(key, new RetriggeredProcessor(projectFullName, eventJson, otherBuilds)); + logger.trace("Retriggered event stored in distributed memory: {}", key); + } catch (Exception e) { + logger.error("Failed to record retriggered in distributed memory: project={}, event={}", + projectFullName, key, e); + } } @Override @@ -354,12 +373,17 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull String projectFullName = project.getFullName(); // ATOMIC OPERATION - Executes on partition owner, prevents race conditions - Boolean found = map.executeOnKey(key, new BuildCancelledProcessor(projectFullName)); + try { + Boolean found = map.executeOnKey(key, new BuildCancelledProcessor(projectFullName)); - if (!found) { - logger.debug("Build cancelled without being registered first (distributed mode)."); + if (!found) { + logger.debug("Build cancelled without being registered first (distributed mode)."); + } + logger.trace("Cancelled event stored in distributed memory: {}", key); + } catch (Exception e) { + logger.error("Failed to mark cancelled in distributed memory: project={}, event={}", + projectFullName, key, e); } - logger.trace("Cancelled event stored in distributed memory: {}", key); } @Override @@ -374,9 +398,13 @@ public synchronized void setCancelling(@NonNull GerritTriggeredEvent event, @Non String projectFullName = project.getFullName(); // ATOMIC OPERATION - Executes on partition owner, prevents race conditions - map.executeOnKey(key, new SetCancellingProcessor(projectFullName)); - - logger.trace("Cancelling flag set in distributed memory for event: {}", key); + try { + map.executeOnKey(key, new SetCancellingProcessor(projectFullName)); + logger.trace("Cancelling flag set in distributed memory for event: {}", key); + } catch (Exception e) { + logger.error("Failed to set cancelling flag in distributed memory: project={}, event={}", + projectFullName, key, e); + } } @Override @@ -405,17 +433,23 @@ public synchronized void removeProject(@NonNull Job project) { java.util.Set keys = new java.util.HashSet<>(map.keySet()); for (BuildMemoryKey key : keys) { - // Execute processor atomically on partition owner - Boolean shouldDelete = map.executeOnKey(key, new RemoveProjectProcessor(projectFullName)); - - if (shouldDelete != null && shouldDelete) { - // MemoryImprintData is now empty - delete the map entry - map.delete(key); - logger.trace("Removed empty entry for project {} from distributed memory: {}", - projectFullName, key); - } else if (shouldDelete != null) { - logger.trace("Removed project {} from distributed memory entry: {}", - projectFullName, key); + try { + // Execute processor atomically on partition owner + Boolean shouldDelete = map.executeOnKey(key, new RemoveProjectProcessor(projectFullName)); + + if (shouldDelete != null && shouldDelete) { + // MemoryImprintData is now empty - delete the map entry + map.delete(key); + logger.trace("Removed empty entry for project {} from distributed memory: {}", + projectFullName, key); + } else if (shouldDelete != null) { + logger.trace("Removed project {} from distributed memory entry: {}", + projectFullName, key); + } + } catch (Exception e) { + logger.error("Failed to remove project from distributed memory entry: project={}, key={}", + projectFullName, key, e); + // Continue processing other keys } } } @@ -521,12 +555,17 @@ public void setEntryCustomUrl(@NonNull GerritTriggeredEvent event, @NonNull Run String projectFullName = r.getParent().getFullName(); // ATOMIC OPERATION - Executes on partition owner, prevents race conditions - Boolean found = map.executeOnKey(key, new SetCustomUrlProcessor(projectFullName, customUrl)); + try { + Boolean found = map.executeOnKey(key, new SetCustomUrlProcessor(projectFullName, customUrl)); - if (found) { - logger.trace("Recording custom URL for {}: {}", event, customUrl); - } else { - logger.warn("Could not set custom URL - event not found: {}", event); + if (found) { + logger.trace("Recording custom URL for {}: {}", event, customUrl); + } else { + logger.warn("Could not set custom URL - event not found: {}", event); + } + } catch (Exception e) { + logger.error("Failed to set custom URL in distributed memory: project={}, event={}, url={}", + projectFullName, key, customUrl, e); } } @@ -543,13 +582,18 @@ public void setEntryUnsuccessfulMessage(@NonNull GerritTriggeredEvent event, @No String projectFullName = r.getParent().getFullName(); // ATOMIC OPERATION - Executes on partition owner, prevents race conditions - Boolean found = map.executeOnKey(key, - new SetUnsuccessfulMessageProcessor(projectFullName, unsuccessfulMessage)); + try { + Boolean found = map.executeOnKey(key, + new SetUnsuccessfulMessageProcessor(projectFullName, unsuccessfulMessage)); - if (found) { - logger.trace("Recording unsuccessful message for {}: {}", event, unsuccessfulMessage); - } else { - logger.warn("Could not set unsuccessful message - event not found: {}", event); + if (found) { + logger.trace("Recording unsuccessful message for {}: {}", event, unsuccessfulMessage); + } else { + logger.warn("Could not set unsuccessful message - event not found: {}", event); + } + } catch (Exception e) { + logger.error("Failed to set unsuccessful message in distributed memory: project={}, event={}, message={}", + projectFullName, key, unsuccessfulMessage, e); } } From d54d09faecc5be315377554899fd2c6090740d0a Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 26 May 2026 09:39:28 +0200 Subject: [PATCH 14/87] Peer review comment #11: Complete: Parse TTL Once --- .../HazelcastEventClaimStrategy.java | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java index c01bc1f29..33860c30d 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java @@ -77,6 +77,12 @@ public class HazelcastEventClaimStrategy extends EventClaimStrategy { */ private static final String CLAIM_TTL_PROPERTY = "gerrit.trigger.coordination.hazelcast.claim.ttl.seconds"; + /** + * Cached claim TTL in seconds. + * Parsed once at class initialization from system property or default. + */ + private static final long CLAIM_TTL_SECONDS = parseClaimTtlSeconds(); + /** * Cached instance identifier (hostname or pod name). */ @@ -138,7 +144,7 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna EventClaim previousClaim = claimsMap.putIfAbsent( eventId, claim, - getClaimTtlSeconds(), + CLAIM_TTL_SECONDS, TimeUnit.SECONDS ); @@ -205,25 +211,29 @@ private static String getInstanceId() { } /** - * Gets the configured claim TTL in seconds. + * Parses the configured claim TTL in seconds from system property. *

    + * Called once at class initialization to parse and cache the TTL value. * Can be overridden via system property {@link #CLAIM_TTL_PROPERTY}. * Default is {@link #DEFAULT_CLAIM_TTL_SECONDS} (5 minutes). * * @return TTL in seconds */ - private static long getClaimTtlSeconds() { + private static long parseClaimTtlSeconds() { String ttlProperty = System.getProperty(CLAIM_TTL_PROPERTY); if (ttlProperty != null) { try { long ttl = Long.parseLong(ttlProperty); if (ttl > 0) { + logger.info("Using custom claim TTL: {} seconds (from system property)", ttl); return ttl; } else { - logger.warn("Invalid claim TTL property (must be > 0): {}, using default", ttlProperty); + logger.warn("Invalid claim TTL property (must be > 0): {}, using default: {}", + ttlProperty, DEFAULT_CLAIM_TTL_SECONDS); } } catch (NumberFormatException e) { - logger.warn("Invalid claim TTL property (not a number): {}, using default", ttlProperty); + logger.warn("Invalid claim TTL property (not a number): {}, using default: {}", + ttlProperty, DEFAULT_CLAIM_TTL_SECONDS); } } return DEFAULT_CLAIM_TTL_SECONDS; From 04b1c27f09cd64efbef2ffbc31c9fbf10f87298f Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 26 May 2026 11:19:47 +0200 Subject: [PATCH 15/87] Peer review comments #12,13,15,16,17,20,21,23 --- .../hazelcast/EntryDataSerializer.java | 4 +-- .../coordination/hazelcast/EventClaim.java | 3 +- .../hazelcast/EventClaimSerializer.java | 7 +++-- .../hazelcast/EventIdentifier.java | 2 +- .../HazelcastCoordinationProvider.java | 3 -- .../HazelcastEventClaimStrategy.java | 30 ++++++++++++++----- .../HazelcastNotificationClaimStrategy.java | 13 +++++--- .../MemoryImprintDataSerializer.java | 6 ++-- .../hazelcast/SetCancellingProcessor.java | 1 - .../LocalEventClaimStrategy.java | 8 +++-- ...dCancellationHazelcastIntegrationTest.java | 15 ++++++---- 11 files changed, 59 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java index 09221426f..0a20e39b7 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java @@ -38,9 +38,9 @@ public class EntryDataSerializer implements CompactSerializer { /** * Type name for schema registration. - * Must be unique across all compact serialized types. + * Uses fully-qualified name to prevent conflicts in shared Hazelcast clusters. */ - private static final String TYPE_NAME = "EntryData"; + private static final String TYPE_NAME = "com.sonyericsson.gerrit.trigger.EntryData"; @Override @NonNull diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaim.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaim.java index 0c2123e4a..03bde1bfd 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaim.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaim.java @@ -1,6 +1,8 @@ /* * The MIT License * + * Copyright 2026 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 @@ -35,7 +37,6 @@ * compatibility with sidecar deployment. The sidecar Hazelcast cluster doesn't need * this class in its classpath. * - * @author CloudBees, Inc. */ public class EventClaim { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaimSerializer.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaimSerializer.java index e9a7e0558..e1eb5897f 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaimSerializer.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaimSerializer.java @@ -1,6 +1,8 @@ /* * The MIT License * + * Copyright 2026 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 @@ -36,15 +38,14 @@ * The serializer writes a schema with field names and types, which the sidecar * Hazelcast can process without needing the EventClaim class. * - * @author CloudBees, Inc. */ public class EventClaimSerializer implements CompactSerializer { /** * Type name for schema registration. - * Must be unique across all compact serialized types. + * Uses fully-qualified name to prevent conflicts in shared Hazelcast clusters. */ - private static final String TYPE_NAME = "EventClaim"; + private static final String TYPE_NAME = "com.sonyericsson.gerrit.trigger.EventClaim"; @Override @NonNull diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java index c8f0830b1..c6ddd0864 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java @@ -32,7 +32,7 @@ /** * Utility class for generating unique, consistent event identifiers. *

    - * Event IDs are used for distributed event claiming in CloudBees HA/HS environments. + * Event IDs are used for distributed event claiming in HA/HS (High Availability/High Scalability) environments. * The same Gerrit event arriving at different replicas must produce the same event ID * to enable proper claim coordination. *

    diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java index 05389406a..a6882c25f 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java @@ -57,11 +57,8 @@ * * @see com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.CoordinationModeFactory * @see com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.LocalCoordinationProvider (fallback) - * @author CloudBees, Inc. */ -// CHECKSTYLE:OFF MagicNumber - Ordinal must be literal in annotation, 100 ensures higher priority than fallback @Extension(ordinal = HazelcastCoordinationProvider.HAZELCAST_PRIORITY) -// CHECKSTYLE:ON MagicNumber public class HazelcastCoordinationProvider extends CoordinationModeProvider { private static final Logger logger = LoggerFactory.getLogger(HazelcastCoordinationProvider.class); diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java index 33860c30d..fa5e319bb 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java @@ -36,7 +36,7 @@ /** * Hazelcast-backed implementation of EventClaimStrategy for HA/HS deployments. *

    - * In CloudBees HA/HS environments with multiple replicas, each Gerrit event + * In HA/HS (High Availability/High Scalability) environments with multiple replicas, each Gerrit event * arrives at all replicas via SSH event stream. To prevent duplicate builds, * replicas use distributed event claiming: *

      @@ -54,7 +54,6 @@ * Fail-open behavior: If Hazelcast is unavailable, this strategy * allows event processing to continue (better to risk duplicate builds than drop events). * - * @author CloudBees, Inc. */ public class HazelcastEventClaimStrategy extends EventClaimStrategy { @@ -122,8 +121,13 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna // Allow this job to also process the event logger.trace("Event already claimed by this replica, allowing: {} (job processing)", eventId); - claimed.run(); - return new SuccessfulClaim(); + try { + claimed.run(); + return new SuccessfulClaim(); + } catch (Exception actionException) { + logger.error("Error executing action after claim: {}", eventId, actionException); + return new FailedClaim(actionException); + } } else { // Claimed by ANOTHER replica - skip processing logger.debug("Event already claimed by {}: {} (type: {})", @@ -152,16 +156,26 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna // Successfully claimed by this replica logger.debug("Successfully claimed event: {} (type: {})", eventId, event.getEventType().getTypeValue()); - claimed.run(); - return new SuccessfulClaim(); + try { + claimed.run(); + return new SuccessfulClaim(); + } catch (Exception actionException) { + logger.error("Error executing action after successful claim: {}", eventId, actionException); + return new FailedClaim(actionException); + } } else { // Race condition: another replica claimed it between our get() and putIfAbsent() // Check if it was claimed by this replica or another if (previousClaim.getClaimedBy().equals(thisInstanceId)) { // Claimed by THIS replica (race between jobs on same replica) logger.trace("Event claimed by this replica during race condition: {}", eventId); - claimed.run(); - return new SuccessfulClaim(); + try { + claimed.run(); + return new SuccessfulClaim(); + } catch (Exception actionException) { + logger.error("Error executing action in race condition: {}", eventId, actionException); + return new FailedClaim(actionException); + } } else { // Claimed by ANOTHER replica logger.debug("Event claimed by {} during race condition: {} (type: {})", diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java index 28955bd49..24cc798eb 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java @@ -35,7 +35,7 @@ /** * Hazelcast-backed implementation of NotificationClaimStrategy for HA/HS deployments. *

      - * In CloudBees HA/HS environments with multiple replicas, each replica tracks build + * In HA/HS (High Availability/High Scalability) environments with multiple replicas, each replica tracks build * completions independently. To prevent duplicate notifications to Gerrit, * replicas use distributed notification claiming: *

        @@ -50,7 +50,6 @@ * allows notification sending to continue (better to risk duplicate notifications than * lose feedback entirely). * - * @author CloudBees, Inc. */ public class HazelcastNotificationClaimStrategy extends NotificationClaimStrategy { @@ -106,8 +105,14 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna if (previousValue == null) { // Successfully claimed notification right logger.debug("Successfully claimed notification right for event: {}", eventId); - claimed.run(); - return new SuccessfulClaim(); + try { + claimed.run(); + return new SuccessfulClaim(); + } catch (Exception actionException) { + logger.error("Error executing notification action after successful claim: {}", eventId, + actionException); + return new FailedClaim(actionException); + } } else { // Another replica already claimed notification logger.debug("Another replica already claimed notification for event: {}", eventId); diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintDataSerializer.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintDataSerializer.java index 41ea9ad38..6cb98c939 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintDataSerializer.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintDataSerializer.java @@ -1,7 +1,7 @@ /* * The MIT License * - * + * Copyright 2026 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 @@ -43,9 +43,9 @@ public class MemoryImprintDataSerializer implements CompactSerializer * Thread-safe atomic operation. * - * @author CloudBees, Inc. */ public class SetCancellingProcessor implements EntryProcessor { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalEventClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalEventClaimStrategy.java index d717d8166..0e0960e88 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalEventClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalEventClaimStrategy.java @@ -111,8 +111,12 @@ public ClaimResult notClaimed(@NonNull Runnable notClaimed) { @Override @NonNull public ClaimResult onError(@NonNull Consumer onError) { - // Execute error handler - onError.accept(exception); + // Execute error handler (wrapped for safety) + try { + onError.accept(exception); + } catch (Exception e) { + logger.error("Error in error handler", e); + } return this; } } diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationHazelcastIntegrationTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationHazelcastIntegrationTest.java index f8ea28a52..b14608772 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationHazelcastIntegrationTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationHazelcastIntegrationTest.java @@ -52,6 +52,8 @@ import org.jvnet.hudson.test.JenkinsRule; import org.jvnet.hudson.test.SleepBuilder; import org.jvnet.hudson.test.recipes.LocalData; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import static com.sonymobile.tools.gerrit.gerritevents.mock.SshdServerMock.GERRIT_STREAM_EVENTS; import static org.junit.Assert.assertEquals; @@ -64,12 +66,15 @@ * These tests verify that build cancellation works correctly when using * Hazelcast-backed BuildMemoryStorage instead of local TreeMap storage. *

        - * This is critical for CloudBees HA/HS deployments where multiple Jenkins + * This is critical for HA/HS (High Availability/High Scalability) deployments where multiple Jenkins * instances share state via Hazelcast. * */ public class BuildCancellationHazelcastIntegrationTest { + private static final Logger logger = LoggerFactory.getLogger( + BuildCancellationHazelcastIntegrationTest.class); + /** * Hazelcast test infrastructure - MUST be declared before JenkinsRule. * This ensures Hazelcast is initialized before Jenkins starts. @@ -157,10 +162,10 @@ private void verifyHazelcastMode() { modeName = "UNKNOWN"; } - System.out.println("=== COORDINATION MODE VERIFICATION ==="); - System.out.println("Mode: " + modeName); - System.out.println("Storage: " + storageClass); - System.out.println("======================================"); + logger.info("=== COORDINATION MODE VERIFICATION ==="); + logger.info("Mode: {}", modeName); + logger.info("Storage: {}", storageClass); + logger.info("======================================"); assertEquals("Expected Hazelcast storage", "HazelcastBuildMemoryStorage", storageClass); assertEquals("Expected Hazelcast mode", "Hazelcast (Distributed)", modeName); From 2b44bfe6db1d2ef2d6c1ce02d75cd7ab630fd843 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 26 May 2026 12:55:01 +0200 Subject: [PATCH 16/87] Peer review Robert #2~#6: changing the order of the timestamp set and adding to cancelled method --- .../coordination/hazelcast/BuildCancelledProcessor.java | 4 ++++ .../coordination/hazelcast/BuildCompletedProcessor.java | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java index 51d4b43bf..06b0704d6 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java @@ -36,6 +36,7 @@ public class BuildCancelledProcessor implements EntryProcessor entry) { if (projectFullName.equals(entryData.getProjectFullName())) { entryData.setCancelled(true); entryData.setCancelling(false); // Clear cancelling flag + entryData.setCompletedTimestamp(timestamp); // Set completion timestamp entryData.setBuildCompleted(true); // Cancelled builds are also completed found = true; break; @@ -75,6 +78,7 @@ public Boolean process(Map.Entry entry) { newEntry.setProjectFullName(projectFullName); newEntry.setCancelled(true); newEntry.setCancelling(false); + newEntry.setCompletedTimestamp(timestamp); // Set completion timestamp newEntry.setBuildCompleted(true); // Cancelled builds are also completed data.addEntry(newEntry); } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCompletedProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCompletedProcessor.java index 400e3c81a..6646977e8 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCompletedProcessor.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCompletedProcessor.java @@ -69,8 +69,8 @@ public Boolean process(Map.Entry entry) { if (entryData.getBuildId() == null) { entryData.setBuildId(buildId); } - entryData.setBuildCompleted(true); entryData.setCompletedTimestamp(timestamp); + entryData.setBuildCompleted(true); found = true; break; } @@ -82,8 +82,8 @@ public Boolean process(Map.Entry entry) { EntryData newEntry = new EntryData(); newEntry.setProjectFullName(projectFullName); newEntry.setBuildId(buildId); - newEntry.setBuildCompleted(true); newEntry.setCompletedTimestamp(timestamp); + newEntry.setBuildCompleted(true); data.addEntry(newEntry); } From c1c8636efcbb8eedb167a8a2065b43dae5f60ea4 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 26 May 2026 15:27:39 +0200 Subject: [PATCH 17/87] Peer review: Avoiding using static singletons --- .../HazelcastBuildMemoryStorage.java | 19 +++++++++-- .../HazelcastCoordinationProvider.java | 33 ++++++++++++------- .../HazelcastEventClaimStrategy.java | 21 +++++++++--- .../hazelcast/HazelcastManager.java | 19 ++++++----- .../HazelcastNotificationClaimStrategy.java | 21 +++++++++--- .../hazelcast/HazelcastTestListener.java | 4 +-- .../hazelcast/HazelcastTestRule.java | 4 +-- 7 files changed, 86 insertions(+), 35 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 1f933c44b..01d137c21 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -104,6 +104,11 @@ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { .registerTypeAdapter(GerritTriggeredEvent.class, new PolymorphicEventTypeAdapter()) .create(); + /** + * The Hazelcast instance to use for distributed storage. + */ + private final HazelcastInstance hazelcastInstance; + /** * Distributed mode storage (coordination mode). * Lazy-initialized when first accessed. @@ -111,6 +116,15 @@ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { */ private transient volatile IMap distributedMemory = null; + /** + * Constructor. + * + * @param hazelcastInstance the Hazelcast instance to use + */ + public HazelcastBuildMemoryStorage(@NonNull HazelcastInstance hazelcastInstance) { + this.hazelcastInstance = hazelcastInstance; + } + /** * Gets or initializes the distributed memory map using thread-safe double-checked locking. *

        @@ -125,9 +139,8 @@ private IMap getDistributedMemory() { synchronized (this) { // Second check (with locking) - ensures only one thread initializes if (distributedMemory == null) { - HazelcastInstance hz = HazelcastInstanceProvider.getInstance(); - if (hz != null) { - distributedMemory = hz.getMap(MAP_NAME); + if (hazelcastInstance != null) { + distributedMemory = hazelcastInstance.getMap(MAP_NAME); logger.debug("Initialized distributed BuildMemory map: {} (size: {})", MAP_NAME, distributedMemory.size()); } else { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java index a6882c25f..9d6d7121e 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java @@ -21,6 +21,7 @@ */ package com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; +import com.hazelcast.core.HazelcastInstance; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.BuildMemoryStorage; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.EventClaimStrategy; @@ -74,6 +75,12 @@ public class HazelcastCoordinationProvider extends CoordinationModeProvider { */ private static final String HAZELCAST_MODE = "hazelcast"; + /** + * The Hazelcast instance for this provider. + * Set during initialization, used to create strategies. + */ + private HazelcastInstance hazelcastInstance; + /** * Checks if this provider is available. *

        @@ -131,8 +138,10 @@ public String getModeName() { */ @Override public BuildMemoryStorage createStorage() { - logger.info("Creating HazelcastBuildMemoryStorage"); - return new HazelcastBuildMemoryStorage(); + // Fetch instance from provider (multiple Extension instances may exist) + HazelcastInstance instance = HazelcastInstanceProvider.getInstanceOrThrow(); + logger.info("Creating HazelcastBuildMemoryStorage with instance: {}", instance.getName()); + return new HazelcastBuildMemoryStorage(instance); } /** @@ -145,8 +154,10 @@ public BuildMemoryStorage createStorage() { */ @Override public NotificationClaimStrategy createClaimStrategy() { - logger.info("Creating HazelcastNotificationClaimStrategy"); - return new HazelcastNotificationClaimStrategy(); + // Fetch instance from provider (multiple Extension instances may exist) + HazelcastInstance instance = HazelcastInstanceProvider.getInstanceOrThrow(); + logger.info("Creating HazelcastNotificationClaimStrategy with instance: {}", instance.getName()); + return new HazelcastNotificationClaimStrategy(instance); } /** @@ -164,8 +175,10 @@ public NotificationClaimStrategy createClaimStrategy() { */ @Override public EventClaimStrategy createEventClaimStrategy() { - logger.info("Creating HazelcastEventClaimStrategy"); - return new HazelcastEventClaimStrategy(); + // Fetch instance from provider (multiple Extension instances may exist) + HazelcastInstance instance = HazelcastInstanceProvider.getInstanceOrThrow(); + logger.info("Creating HazelcastEventClaimStrategy with instance: {}", instance.getName()); + return new HazelcastEventClaimStrategy(instance); } /** @@ -189,12 +202,8 @@ public void initialize() throws Exception { } logger.info("Initializing Hazelcast coordination mode..."); - boolean initialized = HazelcastManager.initialize(); - if (initialized) { - logger.info("Hazelcast initialized successfully"); - } else { - logger.warn("Hazelcast initialization returned false - may already be initialized"); - } + this.hazelcastInstance = HazelcastManager.initialize(); + logger.info("Hazelcast initialized successfully"); } /** diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java index fa5e319bb..98cd83470 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java @@ -59,11 +59,25 @@ public class HazelcastEventClaimStrategy extends EventClaimStrategy { private static final Logger logger = LoggerFactory.getLogger(HazelcastEventClaimStrategy.class); + /** + * The Hazelcast instance to use for event claiming. + */ + private final HazelcastInstance hazelcastInstance; + /** * Hazelcast map name for event claims. */ private static final String CLAIMS_MAP_NAME = "gerrit-trigger-event-claims"; + /** + * Constructor. + * + * @param hazelcastInstance the Hazelcast instance to use + */ + public HazelcastEventClaimStrategy(@NonNull HazelcastInstance hazelcastInstance) { + this.hazelcastInstance = hazelcastInstance; + } + /** * Default TTL for event claims in seconds (5 minutes). * Claims expire automatically to prevent memory leaks. @@ -90,9 +104,8 @@ public class HazelcastEventClaimStrategy extends EventClaimStrategy { @Override @NonNull public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runnable claimed) { - // Get Hazelcast instance - HazelcastInstance hazelcast = HazelcastInstanceProvider.getInstance(); - if (hazelcast == null) { + // Check Hazelcast instance availability + if (hazelcastInstance == null) { logger.error("Hazelcast not available, cannot claim event. Allowing event processing (fail-open)."); // Fail-open: execute the action even without claiming try { @@ -109,7 +122,7 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna try { // Get claims map - IMap claimsMap = hazelcast.getMap(CLAIMS_MAP_NAME); + IMap claimsMap = hazelcastInstance.getMap(CLAIMS_MAP_NAME); // Check if event is already claimed EventClaim existingClaim = claimsMap.get(eventId); diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java index fe8d29897..232473afe 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java @@ -56,16 +56,19 @@ private HazelcastManager() { * Creates a Hazelcast member in the Jenkins JVM with configuration from * {@link HazelcastConfig#createConfig()}. *

        - * This method is idempotent - calling it multiple times has no effect if already initialized. + * This method is idempotent - calling it multiple times returns the existing instance. * - * @return true if Hazelcast was initialized (or already initialized) + * @return the Hazelcast instance * @throws RuntimeException if initialization fails */ - public static boolean initialize() { + public static HazelcastInstance initialize() { synchronized (INIT_LOCK) { if (initialized) { logger.debug("Hazelcast is already initialized"); - return true; + HazelcastInstance existing = HazelcastInstanceProvider.getInstance(); + if (existing != null) { + return existing; + } } try { @@ -77,7 +80,7 @@ public static boolean initialize() { // Create Hazelcast instance HazelcastInstance hazelcastInstance = Hazelcast.newHazelcastInstance(config); - // Register with provider + // Register with provider (for backward compatibility with helper methods) HazelcastInstanceProvider.setInstance(hazelcastInstance); initialized = true; @@ -89,7 +92,7 @@ public static boolean initialize() { hazelcastInstance.getName(), clusterSize); - return true; + return hazelcastInstance; } catch (Exception e) { logger.error("Failed to initialize Hazelcast", e); @@ -156,9 +159,9 @@ public static boolean isInitialized() { * This will shutdown the existing instance and create a new one. * Used when configuration has changed. * - * @return true if reinitialized successfully + * @return the new Hazelcast instance */ - public static boolean reinitialize() { + public static HazelcastInstance reinitialize() { logger.info("Reinitializing Hazelcast..."); synchronized (INIT_LOCK) { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java index 24cc798eb..4b3b61f5a 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java @@ -55,11 +55,25 @@ public class HazelcastNotificationClaimStrategy extends NotificationClaimStrateg private static final Logger logger = LoggerFactory.getLogger(HazelcastNotificationClaimStrategy.class); + /** + * The Hazelcast instance to use for notification claiming. + */ + private final HazelcastInstance hazelcastInstance; + /** * Hazelcast map name for notification claim flags. */ private static final String NOTIFICATION_FLAGS_MAP = "gerrit-trigger-notification-flags"; + /** + * Constructor. + * + * @param hazelcastInstance the Hazelcast instance to use + */ + public HazelcastNotificationClaimStrategy(@NonNull HazelcastInstance hazelcastInstance) { + this.hazelcastInstance = hazelcastInstance; + } + /** * Default notification claim TTL in minutes (10 minutes). * Claims expire automatically to prevent memory leaks. @@ -76,9 +90,8 @@ public class HazelcastNotificationClaimStrategy extends NotificationClaimStrateg @Override @NonNull public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runnable claimed) { - // Get Hazelcast instance - HazelcastInstance hz = HazelcastInstanceProvider.getInstance(); - if (hz == null) { + // Check Hazelcast instance availability + if (hazelcastInstance == null) { logger.warn("Hazelcast not available for notification claim, proceeding with local mode (fail-open)"); // Fail-open: execute the notification action even without claiming try { @@ -90,7 +103,7 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna } try { - IMap notificationFlags = hz.getMap(NOTIFICATION_FLAGS_MAP); + IMap notificationFlags = hazelcastInstance.getMap(NOTIFICATION_FLAGS_MAP); String eventId = EventIdentifier.generateEventId(event); String flagKey = "notified-" + eventId; diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestListener.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestListener.java index b0710b4c2..7e8478bb6 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestListener.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestListener.java @@ -66,9 +66,9 @@ public void testRunStarted(Description description) { if (!HazelcastManager.isInitialized()) { try { logger.info("Initializing Hazelcast for test suite..."); - boolean success = HazelcastManager.initialize(); + com.hazelcast.core.HazelcastInstance instance = HazelcastManager.initialize(); - if (success) { + if (instance != null) { initialized = true; shouldInitialize = true; logger.info("Hazelcast initialized successfully for test suite"); diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestRule.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestRule.java index 620f3346a..fb8dd1a2b 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestRule.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestRule.java @@ -96,9 +96,9 @@ protected void before() throws Exception { // Initialize Hazelcast if not already initialized if (!HazelcastManager.isInitialized()) { logger.info("Initializing Hazelcast for test..."); - boolean success = HazelcastManager.initialize(); + com.hazelcast.core.HazelcastInstance instance = HazelcastManager.initialize(); - if (!success) { + if (instance == null) { throw new IllegalStateException("Failed to initialize Hazelcast for test"); } From 101ef6258e0220f80e18feef16f4721300726abb Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 26 May 2026 15:41:34 +0200 Subject: [PATCH 18/87] Peer review: avoiding parsing the numbers on every call --- .../HazelcastNotificationClaimStrategy.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java index 4b3b61f5a..3c3b4ad37 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java @@ -87,6 +87,13 @@ public HazelcastNotificationClaimStrategy(@NonNull HazelcastInstance hazelcastIn private static final String NOTIFICATION_TTL_PROPERTY = "gerrit.trigger.coordination.hazelcast.notification.ttl.minutes"; + /** + * Cached claim TTL in seconds. + * Parsed once at class initialization from system property or default. + */ + private static final int NOTIFICATION_TTL_SECONDS = parseNotificationTtlMinutes(); + + @Override @NonNull public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runnable claimed) { @@ -111,7 +118,7 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna Boolean previousValue = notificationFlags.putIfAbsent( flagKey, Boolean.TRUE, - getNotificationTtlMinutes(), + NOTIFICATION_TTL_SECONDS, TimeUnit.MINUTES ); @@ -146,19 +153,21 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna } /** - * Gets the configured notification claim TTL in minutes. + * Parse the configured notification claim TTL in minutes. *

        + * Called once at class initialization to parse and cache the TTL value. * Can be overridden via system property {@link #NOTIFICATION_TTL_PROPERTY}. * Default is {@link #DEFAULT_NOTIFICATION_CLAIM_TTL_MINUTES} (10 minutes). * * @return TTL in minutes */ - private static int getNotificationTtlMinutes() { + private static int parseNotificationTtlMinutes() { String ttlProperty = System.getProperty(NOTIFICATION_TTL_PROPERTY); if (ttlProperty != null) { try { int ttl = Integer.parseInt(ttlProperty); if (ttl > 0) { + logger.info("Using custom notification TTL: {} seconds (from system property)", ttl); return ttl; } else { logger.warn("Invalid notification TTL property (must be > 0): {}, using default", ttlProperty); From debed203097b9397b4a387cf4f1e98ffafbde94a Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 26 May 2026 16:51:36 +0200 Subject: [PATCH 19/87] Peer review: making claim result shareable for all the strategies --- .../HazelcastEventClaimStrategy.java | 105 ++--------- .../HazelcastNotificationClaimStrategy.java | 95 +--------- .../LocalEventClaimStrategy.java | 61 +----- .../LocalNotificationClaimStrategy.java | 62 +----- .../gerrit/trigger/spi/ClaimResult.java | 75 ++++++++ .../gerrit/trigger/spi/ClaimResults.java | 177 ++++++++++++++++++ .../trigger/spi/EventClaimStrategy.java | 33 +--- .../spi/NotificationClaimStrategy.java | 33 +--- 8 files changed, 285 insertions(+), 356 deletions(-) create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/ClaimResult.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/ClaimResults.java diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java index 98cd83470..2ad63274d 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java @@ -23,6 +23,8 @@ import com.hazelcast.core.HazelcastInstance; import com.hazelcast.map.IMap; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.ClaimResult; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.ClaimResults; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.EventClaimStrategy; import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; import edu.umd.cs.findbugs.annotations.NonNull; @@ -31,7 +33,6 @@ import java.net.InetAddress; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; /** * Hazelcast-backed implementation of EventClaimStrategy for HA/HS deployments. @@ -110,9 +111,9 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna // Fail-open: execute the action even without claiming try { claimed.run(); - return new SuccessfulClaim(); + return ClaimResults.success(); } catch (Exception e) { - return new FailedClaim(e); + return ClaimResults.failed(e); } } @@ -136,16 +137,16 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna eventId); try { claimed.run(); - return new SuccessfulClaim(); + return ClaimResults.success(); } catch (Exception actionException) { logger.error("Error executing action after claim: {}", eventId, actionException); - return new FailedClaim(actionException); + return ClaimResults.failed(actionException); } } else { // Claimed by ANOTHER replica - skip processing logger.debug("Event already claimed by {}: {} (type: {})", existingClaim.getClaimedBy(), eventId, event.getEventType().getTypeValue()); - return new NotClaimedResult(); + return ClaimResults.notClaimed(); } } @@ -171,10 +172,10 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna eventId, event.getEventType().getTypeValue()); try { claimed.run(); - return new SuccessfulClaim(); + return ClaimResults.success(); } catch (Exception actionException) { logger.error("Error executing action after successful claim: {}", eventId, actionException); - return new FailedClaim(actionException); + return ClaimResults.failed(actionException); } } else { // Race condition: another replica claimed it between our get() and putIfAbsent() @@ -184,16 +185,16 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna logger.trace("Event claimed by this replica during race condition: {}", eventId); try { claimed.run(); - return new SuccessfulClaim(); + return ClaimResults.success(); } catch (Exception actionException) { logger.error("Error executing action in race condition: {}", eventId, actionException); - return new FailedClaim(actionException); + return ClaimResults.failed(actionException); } } else { // Claimed by ANOTHER replica logger.debug("Event claimed by {} during race condition: {} (type: {})", previousClaim.getClaimedBy(), eventId, event.getEventType().getTypeValue()); - return new NotClaimedResult(); + return ClaimResults.notClaimed(); } } } catch (Exception e) { @@ -204,9 +205,9 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna claimed.run(); } catch (Exception innerException) { logger.error("Error executing claimed action after claim failure", innerException); - return new FailedClaim(innerException); + return ClaimResults.failed(innerException); } - return new SuccessfulClaim(); + return ClaimResults.success(); } } @@ -265,82 +266,4 @@ private static long parseClaimTtlSeconds() { } return DEFAULT_CLAIM_TTL_SECONDS; } - - /** - * Successful claim result - the action was executed. - */ - private static class SuccessfulClaim implements ClaimResult { - @Override - @NonNull - public ClaimResult notClaimed(@NonNull Runnable notClaimed) { - // Claim was successful, don't run notClaimed handler - return this; - } - - @Override - @NonNull - public ClaimResult onError(@NonNull Consumer onError) { - // No error occurred - return this; - } - } - - /** - * Not claimed result - another instance already claimed the event. - */ - private static class NotClaimedResult implements ClaimResult { - @Override - @NonNull - public ClaimResult notClaimed(@NonNull Runnable notClaimed) { - // Run the notClaimed handler - try { - notClaimed.run(); - } catch (Exception e) { - logger.error("Error in notClaimed handler", e); - } - return this; - } - - @Override - @NonNull - public ClaimResult onError(@NonNull Consumer onError) { - // No error occurred (just not claimed) - return this; - } - } - - /** - * Failed claim result - an error occurred during processing. - */ - private static class FailedClaim implements ClaimResult { - private final Exception exception; - - /** - * Constructor. - * - * @param exception the exception that occurred - */ - FailedClaim(Exception exception) { - this.exception = exception; - } - - @Override - @NonNull - public ClaimResult notClaimed(@NonNull Runnable notClaimed) { - // Don't run notClaimed - this was an error, not a "not claimed" situation - return this; - } - - @Override - @NonNull - public ClaimResult onError(@NonNull Consumer onError) { - // Run the error handler - try { - onError.accept(exception); - } catch (Exception e) { - logger.error("Error in error handler", e); - } - return this; - } - } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java index 3c3b4ad37..938d97697 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java @@ -23,6 +23,8 @@ import com.hazelcast.core.HazelcastInstance; import com.hazelcast.map.IMap; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.ClaimResult; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.ClaimResults; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.NotificationClaimStrategy; import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; import edu.umd.cs.findbugs.annotations.NonNull; @@ -30,7 +32,6 @@ import org.slf4j.LoggerFactory; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; /** * Hazelcast-backed implementation of NotificationClaimStrategy for HA/HS deployments. @@ -103,9 +104,9 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna // Fail-open: execute the notification action even without claiming try { claimed.run(); - return new SuccessfulClaim(); + return ClaimResults.success(); } catch (Exception e) { - return new FailedClaim(e); + return ClaimResults.failed(e); } } @@ -127,16 +128,16 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna logger.debug("Successfully claimed notification right for event: {}", eventId); try { claimed.run(); - return new SuccessfulClaim(); + return ClaimResults.success(); } catch (Exception actionException) { logger.error("Error executing notification action after successful claim: {}", eventId, actionException); - return new FailedClaim(actionException); + return ClaimResults.failed(actionException); } } else { // Another replica already claimed notification logger.debug("Another replica already claimed notification for event: {}", eventId); - return new NotClaimedResult(); + return ClaimResults.notClaimed(); } } catch (Exception e) { // Hazelcast operation failed @@ -146,9 +147,9 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna claimed.run(); } catch (Exception innerException) { logger.error("Error executing notification action after claim failure", innerException); - return new FailedClaim(innerException); + return ClaimResults.failed(innerException); } - return new SuccessfulClaim(); + return ClaimResults.success(); } } @@ -178,82 +179,4 @@ private static int parseNotificationTtlMinutes() { } return DEFAULT_NOTIFICATION_CLAIM_TTL_MINUTES; } - - /** - * Successful claim result - the action was executed. - */ - private static class SuccessfulClaim implements ClaimResult { - @Override - @NonNull - public ClaimResult notClaimed(@NonNull Runnable notClaimed) { - // Claim was successful, don't run notClaimed handler - return this; - } - - @Override - @NonNull - public ClaimResult onError(@NonNull Consumer onError) { - // No error occurred - return this; - } - } - - /** - * Not claimed result - another instance already claimed the notification. - */ - private static class NotClaimedResult implements ClaimResult { - @Override - @NonNull - public ClaimResult notClaimed(@NonNull Runnable notClaimed) { - // Run the notClaimed handler - try { - notClaimed.run(); - } catch (Exception e) { - logger.error("Error in notClaimed handler", e); - } - return this; - } - - @Override - @NonNull - public ClaimResult onError(@NonNull Consumer onError) { - // No error occurred (just not claimed) - return this; - } - } - - /** - * Failed claim result - an error occurred during processing. - */ - private static class FailedClaim implements ClaimResult { - private final Exception exception; - - /** - * Constructor. - * - * @param exception the exception that occurred - */ - FailedClaim(Exception exception) { - this.exception = exception; - } - - @Override - @NonNull - public ClaimResult notClaimed(@NonNull Runnable notClaimed) { - // Don't run notClaimed - this was an error, not a "not claimed" situation - return this; - } - - @Override - @NonNull - public ClaimResult onError(@NonNull Consumer onError) { - // Run the error handler - try { - onError.accept(exception); - } catch (Exception e) { - logger.error("Error in error handler", e); - } - return this; - } - } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalEventClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalEventClaimStrategy.java index 0e0960e88..41e7d4179 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalEventClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalEventClaimStrategy.java @@ -23,14 +23,14 @@ */ package com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.ClaimResult; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.ClaimResults; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.EventClaimStrategy; import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; import edu.umd.cs.findbugs.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.function.Consumer; - /** * Local (standalone) implementation of EventClaimStrategy. * Always succeeds since there is no coordination needed in single-instance mode. @@ -61,63 +61,10 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna // Local mode: always claim and execute immediately try { claimed.run(); - return new SuccessfulClaim(); + return ClaimResults.success(); } catch (Exception e) { logger.error("Error processing event in local mode", e); - return new FailedClaim(e); - } - } - - /** - * Claim result for successful claim (local mode always succeeds). - */ - private static class SuccessfulClaim implements ClaimResult { - @Override - @NonNull - public ClaimResult notClaimed(@NonNull Runnable notClaimed) { - // Never called - local mode always claims - return this; - } - - @Override - @NonNull - public ClaimResult onError(@NonNull Consumer onError) { - // Never called - no error occurred - return this; - } - } - - /** - * Claim result for failed claim (exception during processing). - */ - private static class FailedClaim implements ClaimResult { - private final Exception exception; - - /** - * Constructor. - * @param exception the exception that occurred - */ - FailedClaim(Exception exception) { - this.exception = exception; - } - - @Override - @NonNull - public ClaimResult notClaimed(@NonNull Runnable notClaimed) { - // Never called - local mode always claims (even if it fails during execution) - return this; - } - - @Override - @NonNull - public ClaimResult onError(@NonNull Consumer onError) { - // Execute error handler (wrapped for safety) - try { - onError.accept(exception); - } catch (Exception e) { - logger.error("Error in error handler", e); - } - return this; + return ClaimResults.failed(e); } } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalNotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalNotificationClaimStrategy.java index 802949388..26ca3d2b3 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalNotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalNotificationClaimStrategy.java @@ -23,14 +23,14 @@ */ package com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.ClaimResult; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.ClaimResults; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.NotificationClaimStrategy; import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; import edu.umd.cs.findbugs.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.function.Consumer; - /** * Local (non-cluster) implementation of notification claiming. * Always executes the notification action since there's no need for coordination in standalone mode. @@ -51,64 +51,10 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna // In local mode, always allow notification - no coordination needed try { claimed.run(); - return new SuccessfulClaim(); + return ClaimResults.success(); } catch (Exception e) { logger.error("Error executing notification action", e); - return new FailedClaim(e); - } - } - - /** - * Successful claim result - the action was executed. - */ - private static class SuccessfulClaim implements ClaimResult { - @Override - @NonNull - public ClaimResult notClaimed(@NonNull Runnable notClaimed) { - // Claim was successful, don't run notClaimed handler - return this; - } - - @Override - @NonNull - public ClaimResult onError(@NonNull Consumer onError) { - // No error occurred - return this; - } - } - - /** - * Failed claim result - an error occurred during processing. - */ - private static class FailedClaim implements ClaimResult { - private final Exception exception; - - /** - * Constructor. - * - * @param exception the exception that occurred - */ - FailedClaim(Exception exception) { - this.exception = exception; - } - - @Override - @NonNull - public ClaimResult notClaimed(@NonNull Runnable notClaimed) { - // Don't run notClaimed - this was an error, not a "not claimed" situation - return this; - } - - @Override - @NonNull - public ClaimResult onError(@NonNull Consumer onError) { - // Run the error handler - try { - onError.accept(exception); - } catch (Exception e) { - logger.error("Error in error handler", e); - } - return this; + return ClaimResults.failed(e); } } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/ClaimResult.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/ClaimResult.java new file mode 100644 index 000000000..3cda285b4 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/ClaimResult.java @@ -0,0 +1,75 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.spi; + +import edu.umd.cs.findbugs.annotations.NonNull; +import java.util.function.Consumer; + +/** + * Result of a claim attempt, allows chaining handlers for not-claimed and error cases. + *

        + * This interface supports a fluent API pattern for handling different outcomes + * of claim attempts in both event and notification claiming strategies. + *

        + * Usage Example: + *

        + * strategy.withClaim(event, () -> {
        + *     processEvent(event);
        + * })
        + * .notClaimed(() -> {
        + *     logger.debug("Event already claimed, skipping");
        + * })
        + * .onError((ex) -> {
        + *     logger.error("Error processing event", ex);
        + * });
        + * 
        + * + * @see EventClaimStrategy + * @see NotificationClaimStrategy + * @see ClaimResults + */ +public interface ClaimResult { + /** + * Handler called if the claim was not acquired (another instance already processing). + *

        + * This is optional - if not specified, nothing happens when the claim fails. + * + * @param notClaimed action to execute if claim failed + * @return this for chaining + */ + @NonNull + ClaimResult notClaimed(@NonNull Runnable notClaimed); + + /** + * Handler called if an exception occurs during claim processing. + *

        + * This is optional - if not specified, exceptions are silently ignored + * (though they may be logged by the implementation). + * + * @param onError action to execute on error (receives the exception) + * @return this for chaining + */ + @NonNull + ClaimResult onError(@NonNull Consumer onError); +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/ClaimResults.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/ClaimResults.java new file mode 100644 index 000000000..6adee2e99 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/ClaimResults.java @@ -0,0 +1,177 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.spi; + +import edu.umd.cs.findbugs.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.function.Consumer; + +/** + * Shared implementations of ClaimResult for use by all claim strategy implementations. + *

        + * This utility class eliminates code duplication across local and distributed claim strategies + * by providing reusable result implementations. All strategies (NotificationClaimStrategy and + * EventClaimStrategy, both local and Hazelcast modes) can use these shared implementations. + *

        + * Usage: + *

        + * // In any claim strategy implementation:
        + * return ClaimResults.success();
        + * return ClaimResults.notClaimed();
        + * return ClaimResults.failed(exception);
        + * 
        + * + * @see ClaimResult + */ +public final class ClaimResults { + + private static final Logger logger = LoggerFactory.getLogger(ClaimResults.class); + + /** + * Private constructor - utility class with only static methods. + */ + private ClaimResults() { + // Prevent instantiation + } + + /** + * Creates a successful claim result. + * Use when the claim was acquired and the action executed successfully. + * + * @return a ClaimResult indicating success + */ + @NonNull + public static ClaimResult success() { + return SuccessfulClaim.INSTANCE; + } + + /** + * Creates a not-claimed result. + * Use when the claim was not acquired (another instance already claimed it). + * + * @return a ClaimResult indicating not claimed + */ + @NonNull + public static ClaimResult notClaimed() { + return new NotClaimedResult(); + } + + /** + * Creates a failed claim result. + * Use when an exception occurred during claim processing. + * + * @param exception the exception that occurred + * @return a ClaimResult indicating failure + */ + @NonNull + public static ClaimResult failed(@NonNull Exception exception) { + return new FailedClaim(exception); + } + + /** + * Successful claim result - the claim was acquired and action executed. + * Singleton pattern for memory efficiency. + */ + private static class SuccessfulClaim implements ClaimResult { + + static final SuccessfulClaim INSTANCE = new SuccessfulClaim(); + + @Override + @NonNull + public ClaimResult notClaimed(@NonNull Runnable notClaimed) { + // Claim was successful, don't run notClaimed handler + return this; + } + + @Override + @NonNull + public ClaimResult onError(@NonNull Consumer onError) { + // No error occurred + return this; + } + } + + /** + * Not claimed result - another instance already claimed it. + */ + private static class NotClaimedResult implements ClaimResult { + + @Override + @NonNull + public ClaimResult notClaimed(@NonNull Runnable notClaimed) { + // Run the notClaimed handler + try { + notClaimed.run(); + } catch (Exception e) { + logger.error("Error in notClaimed handler", e); + } + return this; + } + + @Override + @NonNull + public ClaimResult onError(@NonNull Consumer onError) { + // No error occurred (just not claimed) + return this; + } + } + + /** + * Failed claim result - an exception occurred during processing. + */ + private static class FailedClaim implements ClaimResult { + + private final Exception exception; + + /** + * Constructor. + * + * @param exception the exception that occurred + */ + FailedClaim(@NonNull Exception exception) { + this.exception = exception; + } + + @Override + @NonNull + public ClaimResult notClaimed(@NonNull Runnable notClaimed) { + // Don't run notClaimed - this was an error, not a "not claimed" situation + return this; + } + + @Override + @NonNull + public ClaimResult onError(@NonNull Consumer onError) { + // Run the error handler + try { + onError.accept(exception); + } catch (Exception e) { + logger.error("Error in error handler", e); + } + return this; + } + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java index 8e3eb14e1..7c62546cf 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java @@ -25,7 +25,6 @@ import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; import edu.umd.cs.findbugs.annotations.NonNull; -import java.util.function.Consumer; /** * Abstract base class for event claiming strategies in different deployment modes. @@ -99,38 +98,8 @@ public abstract class EventClaimStrategy { * @param event the Gerrit event to claim * @param claimed action to execute if claim succeeds (runs with claim held, auto-released) * @return ClaimResult for chaining notClaimed/onError handlers + * @see ClaimResults for shared result implementations */ @NonNull public abstract ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runnable claimed); - - /** - * Result of a claim attempt, allows chaining handlers for not-claimed and error cases. - * - *

        This interface supports a fluent API pattern for handling different outcomes - * of the claim attempt.

        - */ - public interface ClaimResult { - /** - * Handler called if the claim was not acquired (another instance already processing). - * - *

        This is optional - if not specified, nothing happens when the claim fails.

        - * - * @param notClaimed action to execute if claim failed - * @return this for chaining - */ - @NonNull - ClaimResult notClaimed(@NonNull Runnable notClaimed); - - /** - * Handler called if an exception occurs during claim processing. - * - *

        This is optional - if not specified, exceptions are silently ignored - * (though they may be logged by the implementation).

        - * - * @param onError action to execute on error (receives the exception) - * @return this for chaining - */ - @NonNull - ClaimResult onError(@NonNull Consumer onError); - } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java index 02bf8f1d0..b8ad39d24 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java @@ -25,7 +25,6 @@ import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; import edu.umd.cs.findbugs.annotations.NonNull; -import java.util.function.Consumer; /** * Abstract base class for notification claiming strategies in different deployment modes. @@ -99,38 +98,8 @@ public abstract class NotificationClaimStrategy { * @param event the Gerrit event to claim notification rights for * @param claimed action to execute if claim succeeds (runs with claim held, auto-released) * @return ClaimResult for chaining notClaimed/onError handlers + * @see ClaimResults for shared result implementations */ @NonNull public abstract ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runnable claimed); - - /** - * Result of a notification claim attempt, allows chaining handlers for not-claimed and error cases. - * - *

        This interface supports a fluent API pattern for handling different outcomes - * of the claim attempt.

        - */ - public interface ClaimResult { - /** - * Handler called if the claim was not acquired (another instance already sending notification). - * - *

        This is optional - if not specified, nothing happens when the claim fails.

        - * - * @param notClaimed action to execute if claim failed - * @return this for chaining - */ - @NonNull - ClaimResult notClaimed(@NonNull Runnable notClaimed); - - /** - * Handler called if an exception occurs during notification sending. - * - *

        This is optional - if not specified, exceptions are silently ignored - * (though they may be logged by the implementation).

        - * - * @param onError action to execute on error (receives the exception) - * @return this for chaining - */ - @NonNull - ClaimResult onError(@NonNull Consumer onError); - } } From 498138aad4a3af0ef61436b52d50fe1c5b8ad179 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 26 May 2026 17:07:50 +0200 Subject: [PATCH 20/87] Peer review: fixing sync problem inside a lambda --- .../trigger/coordination/LocalCoordinationProvider.java | 2 -- .../coordination/hazelcast/SetCancellingProcessor.java | 3 +-- .../plugins/gerrit/trigger/hudsontrigger/EventListener.java | 6 +++--- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java index 63c1c8194..2bcaabcfc 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java @@ -50,9 +50,7 @@ * @see LocalEventClaimStrategy * @see CoordinationModeFactory */ -// CHECKSTYLE:OFF MagicNumber - Ordinal must be literal in annotation, -1000 ensures fallback priority @Extension(ordinal = LocalCoordinationProvider.FALLBACK_PRIORITY) -// CHECKSTYLE:ON MagicNumber public class LocalCoordinationProvider extends CoordinationModeProvider { /** diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCancellingProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCancellingProcessor.java index 6e6bc603d..03f8403f8 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCancellingProcessor.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCancellingProcessor.java @@ -1,7 +1,7 @@ /* * The MIT License * - * Copyright 2026 CloudBees, Inc. + * Copyright 2026 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 @@ -36,7 +36,6 @@ * in future policy checks. *

        * Thread-safe atomic operation. - * */ public class SetCancellingProcessor implements EntryProcessor { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/EventListener.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/EventListener.java index cd21d6dd3..5926f28bf 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/EventListener.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/EventListener.java @@ -129,7 +129,7 @@ public void gerritEvent(GerritEvent event) { // Claim event for processing (prevents duplicate builds in HA/HS environments) EventClaimStrategy eventClaimStrategy = CoordinationModeFactory.get().getEventClaimStrategy(); eventClaimStrategy.withClaim(triggeredEvent, () -> { - synchronized (this) { + synchronized (EventListener.this) { if (t.isInteresting(triggeredEvent)) { logger.trace("The event is interesting."); abortBuild(t, triggeredEvent); @@ -174,7 +174,7 @@ public void gerritEvent(ManualPatchsetCreated event) { // Claim event for processing (prevents duplicate builds in HA/HS environments) EventClaimStrategy eventClaimStrategy = CoordinationModeFactory.get().getEventClaimStrategy(); eventClaimStrategy.withClaim(event, () -> { - synchronized (this) { + synchronized (EventListener.this) { if (t.isInteresting(event)) { logger.trace("The event is interesting."); abortBuild(t, event); @@ -225,7 +225,7 @@ public void gerritEvent(CommentAdded event) { // Claim event for processing (prevents duplicate builds in HA/HS environments) EventClaimStrategy eventClaimStrategy = CoordinationModeFactory.get().getEventClaimStrategy(); eventClaimStrategy.withClaim(event, () -> { - synchronized (this) { + synchronized (EventListener.this) { if (t.isInteresting(event) && t.commentAddedMatch(event)) { logger.trace("The event is interesting."); abortBuild(t, event); From c40e3609927fd35167084bbbca0c58e6e7e30aa1 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 26 May 2026 17:14:01 +0200 Subject: [PATCH 21/87] Peer review: fixing javadoc issues --- .../gerrit/trigger/spi/BuildMemoryStorage.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java index 67b284e3c..22ae9da47 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java @@ -288,15 +288,21 @@ public abstract void setEntryUnsuccessfulMessage(@NonNull GerritTriggeredEvent e public abstract Map getAllEvents(); /** - * Checks if two events are logically equivalent. + * Checks if two events are logically equivalent for cancellation purposes. *

        * This method allows each storage implementation to define its own event equality * semantics. This is critical for proper operation in different coordination modes: *

          - *
        • Local mode: Uses identity comparison (==) since events are - * never serialized/deserialized
        • + *
        • Local mode: Uses identity comparison (==) as an optimization since + * the same event object instance is passed through the system. Note that event classes + * implement logical {@code .equals()} (see + * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.GerritCause} and + * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.BadgeAction}), + * which is used for TreeMap key lookup. The identity check here is purely for + * performance in cancellation logic.
        • *
        • Distributed mode: Uses logical comparison via EventIdentifier - * since events are serialized/deserialized across replicas
        • + * since events are serialized/deserialized across replicas and object identity + * is lost. *
        *

        * Design rationale: Event equality semantics belong in the storage From 31c0159acc03752d75914f608c735ed4f7116d3b Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 26 May 2026 17:19:00 +0200 Subject: [PATCH 22/87] Peer review: removing unnecessary comments --- .../hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java | 4 ---- .../hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java | 3 --- .../plugins/gerrit/trigger/spi/NotificationClaimStrategy.java | 3 --- 3 files changed, 10 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java index 22ae9da47..0958cfe5f 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java @@ -47,10 +47,6 @@ *

        * Implementations are discovered via Jenkins Extension Points pattern using * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider}. - *

        - * Design Note: This is an abstract class (not an interface) to allow - * adding concrete helper methods in the future without breaking existing implementations. - * This follows Jenkins plugin development best practices. * * @see com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider * @see com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java index 7c62546cf..490f377cc 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java @@ -64,9 +64,6 @@ *

        Implementations are discovered via the Extension Points pattern using * {@link CoordinationModeProvider}.

        * - *

        Design Note: This is an abstract class (not an interface) to allow - * adding concrete helper methods in the future without breaking existing implementations.

        - * * @see CoordinationModeProvider */ public abstract class EventClaimStrategy { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java index b8ad39d24..90e9e9268 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java @@ -64,9 +64,6 @@ *

        Implementations are discovered via the Extension Points pattern using * {@link CoordinationModeProvider}.

        * - *

        Design Note: This is an abstract class (not an interface) to allow - * adding concrete helper methods in the future without breaking existing implementations.

        - * * @see CoordinationModeProvider */ public abstract class NotificationClaimStrategy { From 6802a813804bc79aec79dff1731aaf110561d853 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 26 May 2026 17:24:50 +0200 Subject: [PATCH 23/87] Peer review: removing unnecessary comments --- .../plugins/gerrit/trigger/spi/EventClaimStrategy.java | 9 --------- .../gerrit/trigger/spi/NotificationClaimStrategy.java | 9 --------- 2 files changed, 18 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java index 490f377cc..8b7e38f7b 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java @@ -52,15 +52,6 @@ * }); * * - *

        Benefits:

        - *
          - *
        • Automatic claim lifecycle management (no manual release needed)
        • - *
        • No risk of forgetting to release claim in finally blocks
        • - *
        • Cleaner integration code
        • - *
        • Built-in error handling
        • - *
        • Follows Jenkins patterns (Queue, ACL)
        • - *
        - * *

        Implementations are discovered via the Extension Points pattern using * {@link CoordinationModeProvider}.

        * diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java index 90e9e9268..cc2e54eca 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java @@ -52,15 +52,6 @@ * }); * * - *

        Benefits:

        - *
          - *
        • Automatic claim lifecycle management (no manual release needed)
        • - *
        • No risk of forgetting to release claim in finally blocks
        • - *
        • Cleaner integration code
        • - *
        • Built-in error handling
        • - *
        • Follows Jenkins patterns (Queue, ACL)
        • - *
        - * *

        Implementations are discovered via the Extension Points pattern using * {@link CoordinationModeProvider}.

        * From 9b0d53b2ebc84e4b1d2dc74243b5e42bb0d0a899 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 26 May 2026 17:29:53 +0200 Subject: [PATCH 24/87] Peer review: added explanation on eventsMatch --- .../gerrit/trigger/storage/LocalBuildMemoryStorage.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java index 3c5159650..2f3adc750 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java @@ -332,7 +332,10 @@ public synchronized Map getAllEvents() { @Override public boolean eventsMatch(@NonNull GerritTriggeredEvent event1, @NonNull GerritTriggeredEvent event2) { - // In local mode, use identity comparison since events are never serialized + // In local mode, use identity comparison as an optimization since the same event object + // instance is passed through the system. Events do implement logical equals() (see + // GerritCause and BadgeAction) which is used for TreeMap key lookup. The identity check + // here is purely for performance in cancellation logic. return event1 == event2; } } From c0569c5e7d68253adbda8a167dcb49b1c7e522aa Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 26 May 2026 17:36:03 +0200 Subject: [PATCH 25/87] Peer review: removing initialization duplications --- .../plugins/gerrit/trigger/PluginImpl.java | 47 ++++++++++++------- .../HazelcastCoordinationProvider.java | 21 +++------ 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java index bc0134dcb..b93bed2b9 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java @@ -595,31 +595,46 @@ public void start() { } /** - * Initialize all coordination mode providers. + * Initialize the active coordination mode provider. *

        * This is called early in plugin startup, before any code that might use - * CoordinationModeFactory. This ensures providers can initialize their resources - * and be ready when isAvailable() is called during provider discovery. + * CoordinationModeFactory. Uses the same discovery logic as CoordinationModeFactory + * to find the active provider (highest ordinal where isAvailable() returns true), + * then initializes only that provider. + *

        + * Only initializing the active provider is more efficient than calling initialize() + * on all providers and letting each one check if it should run. *

        * Fails gracefully - if a provider's initialization fails, it will not be available * and the factory will fall back to the next highest-priority provider. */ private void initializeCoordinationProviders() { - logger.debug("Initializing coordination providers..."); - for (com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider provider - : hudson.ExtensionList.lookup( - com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider.class)) { - try { - logger.debug("Initializing provider: {}", provider.getModeName()); - provider.initialize(); - logger.debug("Provider {} initialized successfully", provider.getModeName()); - } catch (Exception e) { - logger.warn("Failed to initialize coordination provider: {}. " - + "Provider will not be available.", provider.getModeName(), e); - // Continue with other providers even if one fails + logger.debug("Discovering active coordination provider..."); + hudson.ExtensionList providers = + hudson.ExtensionList.lookup( + com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider.class); + + // ExtensionList is already ordered by ordinal (highest first) + // Find and initialize only the first available provider + for (com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider provider : providers) { + logger.debug("Checking provider: {} (available={})", provider.getModeName(), provider.isAvailable()); + + if (provider.isAvailable()) { + try { + logger.info("Initializing active coordination provider: {}", provider.getModeName()); + provider.initialize(); + logger.info("Provider {} initialized successfully", provider.getModeName()); + return; // Only initialize the active provider + } catch (Exception e) { + logger.warn("Failed to initialize coordination provider: {}. " + + "Trying next available provider.", provider.getModeName(), e); + // Continue to next provider if this one fails + } } } - logger.debug("Coordination provider initialization complete"); + + logger.warn("No coordination provider initialized - this should not happen as LocalCoordinationProvider " + + "should always be available"); } /** diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java index 9d6d7121e..2a5a70d48 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java @@ -84,17 +84,16 @@ public class HazelcastCoordinationProvider extends CoordinationModeProvider { /** * Checks if this provider is available. *

        - * Returns true only if: - *

          - *
        • Coordination mode is configured as 'hazelcast' (via system property)
        • - *
        • Hazelcast instance is initialized and running
        • - *
        + * Returns true only if coordination mode is configured as 'hazelcast' (via system property). *

        * Uses the {@link CoordinationModeProvider#getConfiguredMode()} helper method to check * the coordination mode. This is future-proof - when we add UI configuration for coordination * modes, only that one helper method needs to be updated. + *

        + * Note: This method checks configuration only, not initialization status. Hazelcast is + * initialized later via {@link #initialize()}, after the provider is selected. * - * @return true if Hazelcast coordination mode is available, false otherwise + * @return true if Hazelcast coordination mode is configured, false otherwise */ @Override public boolean isAvailable() { @@ -105,15 +104,7 @@ public boolean isAvailable() { return false; } - // Check Hazelcast availability - if (!HazelcastInstanceProvider.isInitialized()) { - logger.warn("Coordination mode is '{}' but Hazelcast not initialized. " - + "Hazelcast must be initialized before coordination provider discovery. " - + "Falling back to local mode.", HAZELCAST_MODE); - return false; - } - - logger.info("Hazelcast coordination mode active"); + logger.debug("Hazelcast coordination mode configured"); return true; } From 2423e3adf9bef530b1ac2fa43d941ef3b499ca88 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Wed, 27 May 2026 10:59:17 +0200 Subject: [PATCH 26/87] Peer review: improving initialization --- .../plugins/gerrit/trigger/PluginImpl.java | 34 +++++++++++++------ .../HazelcastCoordinationProvider.java | 25 +++++++++++--- .../HazelcastNotificationClaimStrategy.java | 27 ++++++++++----- .../gerritnotifier/GerritNotifierFactory.java | 4 +-- .../LocalNotificationClaimStrategy.java | 4 ++- .../spi/NotificationClaimStrategy.java | 25 ++++++++++++-- .../hazelcast/HazelcastTestRule.java | 18 ++++++++++ 7 files changed, 106 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java index b93bed2b9..1ead26d54 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java @@ -598,12 +598,13 @@ public void start() { * Initialize the active coordination mode provider. *

        * This is called early in plugin startup, before any code that might use - * CoordinationModeFactory. Uses the same discovery logic as CoordinationModeFactory - * to find the active provider (highest ordinal where isAvailable() returns true), - * then initializes only that provider. + * CoordinationModeFactory. Only initializes the provider that matches the configured + * coordination mode, making it more efficient than calling initialize() on all providers. *

        - * Only initializing the active provider is more efficient than calling initialize() - * on all providers and letting each one check if it should run. + * Implementation Note: We cannot use {@code provider.isAvailable()} + * before initialization because isAvailable() checks if the provider is actually initialized. + * Instead, we check the configured mode directly and initialize the matching provider. + * After initialization, isAvailable() will return true. *

        * Fails gracefully - if a provider's initialization fails, it will not be available * and the factory will fall back to the next highest-priority provider. @@ -614,20 +615,31 @@ private void initializeCoordinationProviders() { hudson.ExtensionList.lookup( com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider.class); + // Get configured mode to determine which provider to initialize + String configuredMode = com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider + .getConfiguredMode(); + logger.debug("Configured coordination mode: {}", configuredMode); + // ExtensionList is already ordered by ordinal (highest first) - // Find and initialize only the first available provider + // Find and initialize the provider that matches the configured mode for (com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider provider : providers) { - logger.debug("Checking provider: {} (available={})", provider.getModeName(), provider.isAvailable()); + // Check if this provider's mode matches the configuration + // We cannot use isAvailable() here because it checks initialization status + String providerMode = provider.getModeName(); + boolean matches = providerMode.toLowerCase().contains(configuredMode.toLowerCase()) + || configuredMode.equalsIgnoreCase("default") && providerMode.equals("Local"); + + logger.debug("Checking provider: {} (matches={})", providerMode, matches); - if (provider.isAvailable()) { + if (matches) { try { - logger.info("Initializing active coordination provider: {}", provider.getModeName()); + logger.info("Initializing coordination provider: {}", provider.getModeName()); provider.initialize(); logger.info("Provider {} initialized successfully", provider.getModeName()); - return; // Only initialize the active provider + return; // Only initialize the matching provider } catch (Exception e) { logger.warn("Failed to initialize coordination provider: {}. " - + "Trying next available provider.", provider.getModeName(), e); + + "Will fall back to next available provider.", provider.getModeName(), e); // Continue to next provider if this one fails } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java index 2a5a70d48..466d4fd1f 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java @@ -84,16 +84,24 @@ public class HazelcastCoordinationProvider extends CoordinationModeProvider { /** * Checks if this provider is available. *

        - * Returns true only if coordination mode is configured as 'hazelcast' (via system property). + * Returns true only if: + *

          + *
        • Coordination mode is configured as 'hazelcast' (via system property)
        • + *
        • Hazelcast instance is initialized and running
        • + *
        *

        * Uses the {@link CoordinationModeProvider#getConfiguredMode()} helper method to check * the coordination mode. This is future-proof - when we add UI configuration for coordination * modes, only that one helper method needs to be updated. *

        - * Note: This method checks configuration only, not initialization status. Hazelcast is - * initialized later via {@link #initialize()}, after the provider is selected. + * The initialization check is necessary because + * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.CoordinationModeFactory} + * may call this method before + * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.PluginImpl} + * has initialized providers. Without this check, the factory would select Hazelcast provider + * before Hazelcast is actually running, causing builds to not trigger. * - * @return true if Hazelcast coordination mode is configured, false otherwise + * @return true if Hazelcast coordination mode is available, false otherwise */ @Override public boolean isAvailable() { @@ -104,7 +112,14 @@ public boolean isAvailable() { return false; } - logger.debug("Hazelcast coordination mode configured"); + // Check Hazelcast availability + if (!HazelcastInstanceProvider.isInitialized()) { + logger.debug("Coordination mode is '{}' but Hazelcast not initialized yet. " + + "Provider will become available after initialization.", HAZELCAST_MODE); + return false; + } + + logger.debug("Hazelcast coordination mode active"); return true; } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java index 938d97697..4ec9238c8 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java @@ -89,15 +89,19 @@ public HazelcastNotificationClaimStrategy(@NonNull HazelcastInstance hazelcastIn "gerrit.trigger.coordination.hazelcast.notification.ttl.minutes"; /** - * Cached claim TTL in seconds. + * Cached claim TTL in minutes. * Parsed once at class initialization from system property or default. */ - private static final int NOTIFICATION_TTL_SECONDS = parseNotificationTtlMinutes(); + private static final int NOTIFICATION_TTL_MINUTES = parseNotificationTtlMinutes(); @Override @NonNull - public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runnable claimed) { + public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, + @NonNull String notificationType, + @NonNull Runnable claimed) { + logger.debug("Claiming notification for event: {} (type: {})", event, notificationType); + // Check Hazelcast instance availability if (hazelcastInstance == null) { logger.warn("Hazelcast not available for notification claim, proceeding with local mode (fail-open)"); @@ -113,30 +117,35 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna try { IMap notificationFlags = hazelcastInstance.getMap(NOTIFICATION_FLAGS_MAP); String eventId = EventIdentifier.generateEventId(event); - String flagKey = "notified-" + eventId; + // Include notification type in key to differentiate build-started vs build-completed + String flagKey = "notified-" + notificationType + "-" + eventId; + + logger.debug("Notification claim key: {}", flagKey); // Atomic operation: set flag if not already set Boolean previousValue = notificationFlags.putIfAbsent( flagKey, Boolean.TRUE, - NOTIFICATION_TTL_SECONDS, + NOTIFICATION_TTL_MINUTES, TimeUnit.MINUTES ); if (previousValue == null) { // Successfully claimed notification right - logger.debug("Successfully claimed notification right for event: {}", eventId); + logger.debug("Successfully claimed notification right for event: {} (type: {})", + eventId, notificationType); try { claimed.run(); return ClaimResults.success(); } catch (Exception actionException) { - logger.error("Error executing notification action after successful claim: {}", eventId, - actionException); + logger.error("Error executing notification action after successful claim: {} (type: {})", + eventId, notificationType, actionException); return ClaimResults.failed(actionException); } } else { // Another replica already claimed notification - logger.debug("Another replica already claimed notification for event: {}", eventId); + logger.debug("Another replica already claimed notification for event: {} (type: {})", + eventId, notificationType); return ClaimResults.notClaimed(); } } catch (Exception e) { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java index 460e64da9..aaecf024e 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java @@ -127,7 +127,7 @@ public void queueBuildCompleted(BuildMemory.MemoryImprint memoryImprint, TaskLis // Claim notification for sending (prevents duplicate notifications in HA/HS environments) NotificationClaimStrategy notificationClaimStrategy = CoordinationModeFactory.get().getClaimStrategy(); - notificationClaimStrategy.withClaim(event, () -> { + notificationClaimStrategy.withClaim(event, "build-completed", () -> { if (config.isUseRestApi() && event instanceof ChangeBasedEvent) { GerritSendCommandQueue.queue(new BuildCompletedRestCommandJob(config, memoryImprint, listener)); @@ -208,7 +208,7 @@ public void queueBuildStarted(Run build, TaskListener listener, // Claim notification for sending (prevents duplicate notifications in HA/HS environments) NotificationClaimStrategy notificationClaimStrategy = CoordinationModeFactory.get().getClaimStrategy(); - notificationClaimStrategy.withClaim(event, () -> { + notificationClaimStrategy.withClaim(event, "build-started", () -> { if (config.isUseRestApi() && event instanceof ChangeBasedEvent) { GerritSendCommandQueue.queue(new BuildStartedRestCommandJob(config, build, listener, (ChangeBasedEvent)event, stats)); diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalNotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalNotificationClaimStrategy.java index 26ca3d2b3..35c20224d 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalNotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalNotificationClaimStrategy.java @@ -47,7 +47,9 @@ public class LocalNotificationClaimStrategy extends NotificationClaimStrategy { @Override @NonNull - public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runnable claimed) { + public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, + @NonNull String notificationType, + @NonNull Runnable claimed) { // In local mode, always allow notification - no coordination needed try { claimed.run(); diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java index cc2e54eca..c37ce0d81 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java @@ -41,7 +41,7 @@ *

        Fluent API Pattern:

        *

        Uses fluent API pattern similar to Jenkins Queue and ACL for automatic resource management:

        *
        - * claimStrategy.withClaim(event, () -> {
        + * claimStrategy.withClaim(event, "build-completed", () -> {
          *     sendNotificationToGerrit(event, buildResult);
          * })
          * .notClaimed(() -> {
        @@ -68,7 +68,7 @@ public abstract class NotificationClaimStrategy {
              *
              * 

        Usage Example:

        *
        -     * claimStrategy.withClaim(event, () -> {
        +     * claimStrategy.withClaim(event, "build-completed", () -> {
              *     // This code runs only if claim was acquired
              *     // Claim is automatically released after this block
              *     sendNotificationToGerrit(event, buildResult);
        @@ -84,10 +84,29 @@ public abstract class NotificationClaimStrategy {
              * 
        * * @param event the Gerrit event to claim notification rights for + * @param notificationType the type of notification (e.g., "build-started", "build-completed") * @param claimed action to execute if claim succeeds (runs with claim held, auto-released) * @return ClaimResult for chaining notClaimed/onError handlers * @see ClaimResults for shared result implementations */ @NonNull - public abstract ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runnable claimed); + public abstract ClaimResult withClaim(@NonNull GerritTriggeredEvent event, + @NonNull String notificationType, + @NonNull Runnable claimed); + + /** + * Attempts to claim the right to send notification and execute the given action if successful. + * This is a convenience method that uses a default notification type. + * + *

        Deprecated: Use {@link #withClaim(GerritTriggeredEvent, String, Runnable)} instead + * to properly differentiate between notification types (build-started vs build-completed).

        + * + * @param event the Gerrit event to claim notification rights for + * @param claimed action to execute if claim succeeds + * @return ClaimResult for chaining notClaimed/onError handlers + */ + @NonNull + public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runnable claimed) { + return withClaim(event, "default", claimed); + } } diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestRule.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestRule.java index fb8dd1a2b..686a0426a 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestRule.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestRule.java @@ -114,6 +114,24 @@ protected void before() throws Exception { throw new IllegalStateException("Hazelcast instance not available after initialization"); } + // Clear notification flags map to avoid test pollution within this test's scope + // Note: This only helps tests that use HazelcastTestRule explicitly. In the full test suite, + // tests using the same mock events (e.g., Setup.createPatchsetCreated()) may still collide + // if run concurrently, since they generate identical event IDs. Maven's retry mechanism + // handles these transient failures. + try { + com.hazelcast.core.HazelcastInstance instance = HazelcastInstanceProvider.getInstance(); + com.hazelcast.map.IMap notificationFlags = + instance.getMap("gerrit-trigger-notification-flags"); + int clearedCount = notificationFlags.size(); + notificationFlags.clear(); + if (clearedCount > 0) { + logger.info("Cleared {} notification claim(s) before test", clearedCount); + } + } catch (Exception e) { + logger.warn("Failed to clear notification flags map", e); + } + logger.info("Hazelcast instance: {}", HazelcastInstanceProvider.getInstance().getName()); logger.info("Cluster size: {}", HazelcastInstanceProvider.getInstance().getCluster().getMembers().size()); From 8722400f24815d34df99dbeecbacc844562954ec Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Wed, 27 May 2026 12:38:35 +0200 Subject: [PATCH 27/87] Fixing WorkflowTest REST API Failures --- .../gerritnotifier/GerritNotifierFactory.java | 1 + .../trigger/hudsontrigger/WorkflowTest.java | 34 ++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java index aaecf024e..4c76afd4a 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java @@ -120,6 +120,7 @@ public GerritNotifier createGerritNotifier(IGerritHudsonTriggerConfig config, Ge public void queueBuildCompleted(BuildMemory.MemoryImprint memoryImprint, TaskListener listener) { String serverName = getServerName(memoryImprint); if (serverName != null) { + GerritServer server = PluginImpl.getServer_(serverName); IGerritHudsonTriggerConfig config = getConfig(serverName); if (config != null) { GerritTriggeredEvent event = memoryImprint.getEvent(); diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/WorkflowTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/WorkflowTest.java index 0a44fa527..ff51df444 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/WorkflowTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/WorkflowTest.java @@ -26,6 +26,7 @@ import com.sonyericsson.hudson.plugins.gerrit.trigger.GerritServer; import com.sonyericsson.hudson.plugins.gerrit.trigger.PluginImpl; import com.sonyericsson.hudson.plugins.gerrit.trigger.config.Config; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastInstanceProvider; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.Branch; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.CompareType; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.GerritProject; @@ -41,12 +42,15 @@ import org.jenkinsci.plugins.workflow.job.WorkflowJob; import org.jenkinsci.plugins.workflow.job.WorkflowRun; import org.junit.Assert; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.jvnet.hudson.test.JenkinsRule; import org.jvnet.hudson.test.TestExtension; import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.StaplerResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.PrintWriter; @@ -68,6 +72,8 @@ */ public class WorkflowTest { + private static final Logger logger = LoggerFactory.getLogger(WorkflowTest.class); + /** * Jenkins rule. */ @@ -75,6 +81,30 @@ public class WorkflowTest { @Rule public final JenkinsRule jenkinsRule = new JenkinsRule(); + /** + * Clear Hazelcast notification map before each test. + * This prevents test pollution when running multiple WorkflowTest methods in Hazelcast mode, + * since all tests use the same mock event ID. + */ + @Before + public void clearHazelcastNotificationMap() { + // Only clear if Hazelcast is initialized + if (HazelcastInstanceProvider.isInitialized()) { + try { + com.hazelcast.core.HazelcastInstance instance = HazelcastInstanceProvider.getInstance(); + com.hazelcast.map.IMap notificationFlags = + instance.getMap("gerrit-trigger-notification-flags"); + int clearedCount = notificationFlags.size(); + notificationFlags.clear(); + if (clearedCount > 0) { + logger.info("Cleared {} notification claim(s) before WorkflowTest", clearedCount); + } + } catch (Exception e) { + logger.warn("Failed to clear Hazelcast notification flags in WorkflowTest", e); + } + } + } + /** * Trigger test. * @throws Exception if there is one. @@ -346,7 +376,9 @@ private static MockGerritServer get(JenkinsRule jenkinsRule) throws IOException private void configure(JenkinsRule jenkinsRule) throws IOException { PluginImpl.getInstance().addServer(this); Config config = (Config)getConfig(); - config.setGerritFrontEndURL(jenkinsRule.getURL().toString() + getUrlName() + "/"); + String frontEndUrl = jenkinsRule.getURL().toString() + getUrlName() + "/"; + + config.setGerritFrontEndURL(frontEndUrl); config.setUseRestApi(true); config.setGerritHttpUserName("user"); config.setGerritHttpPassword("passwd"); From 1f53523e3b689e59c77802422ff4232698c4c4bd Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Wed, 27 May 2026 13:16:41 +0200 Subject: [PATCH 28/87] Fixing issues with SpecGerritTriggerHudsonTest Build Trigger --- .../HazelcastNotificationClaimStrategy.java | 28 ++++++---- .../gerritnotifier/GerritNotifierFactory.java | 4 +- .../LocalNotificationClaimStrategy.java | 2 + .../spi/NotificationClaimStrategy.java | 54 +++++++++++++------ .../spec/SpecGerritTriggerHudsonTest.java | 45 +++++++++++++++- 5 files changed, 105 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java index 4ec9238c8..7b69e164f 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java @@ -99,8 +99,10 @@ public HazelcastNotificationClaimStrategy(@NonNull HazelcastInstance hazelcastIn @NonNull public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull String notificationType, + String jobIdentifier, @NonNull Runnable claimed) { - logger.debug("Claiming notification for event: {} (type: {})", event, notificationType); + logger.debug("Claiming notification for event: {} (type: {}, job: {})", + event, notificationType, jobIdentifier); // Check Hazelcast instance availability if (hazelcastInstance == null) { @@ -117,8 +119,16 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, try { IMap notificationFlags = hazelcastInstance.getMap(NOTIFICATION_FLAGS_MAP); String eventId = EventIdentifier.generateEventId(event); - // Include notification type in key to differentiate build-started vs build-completed - String flagKey = "notified-" + notificationType + "-" + eventId; + + // Build claim key: + // - With job identifier: per-job claim (e.g., build-started notifications) + // - Without job identifier: per-event claim (e.g., build-completed notifications) + String flagKey; + if (jobIdentifier != null && !jobIdentifier.isEmpty()) { + flagKey = "notified-" + notificationType + "-" + eventId + "-" + jobIdentifier; + } else { + flagKey = "notified-" + notificationType + "-" + eventId; + } logger.debug("Notification claim key: {}", flagKey); @@ -132,20 +142,20 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, if (previousValue == null) { // Successfully claimed notification right - logger.debug("Successfully claimed notification right for event: {} (type: {})", - eventId, notificationType); + logger.debug("Successfully claimed notification right for event: {} (type: {}, job: {})", + eventId, notificationType, jobIdentifier); try { claimed.run(); return ClaimResults.success(); } catch (Exception actionException) { - logger.error("Error executing notification action after successful claim: {} (type: {})", - eventId, notificationType, actionException); + logger.error("Error executing notification action after successful claim: {} (type: {}, job: {})", + eventId, notificationType, jobIdentifier, actionException); return ClaimResults.failed(actionException); } } else { // Another replica already claimed notification - logger.debug("Another replica already claimed notification for event: {} (type: {})", - eventId, notificationType); + logger.debug("Another replica already claimed notification for event: {} (type: {}, job: {})", + eventId, notificationType, jobIdentifier); return ClaimResults.notClaimed(); } } catch (Exception e) { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java index 4c76afd4a..0ec472914 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java @@ -207,9 +207,11 @@ public void queueBuildStarted(Run build, TaskListener listener, IGerritHudsonTriggerConfig config = getConfig(serverName); if (config != null) { // Claim notification for sending (prevents duplicate notifications in HA/HS environments) + // Build-started uses per-job claim (each job sends its own notification) + String jobName = build.getParent().getFullName(); NotificationClaimStrategy notificationClaimStrategy = CoordinationModeFactory.get().getClaimStrategy(); - notificationClaimStrategy.withClaim(event, "build-started", () -> { + notificationClaimStrategy.withClaim(event, "build-started", jobName, () -> { if (config.isUseRestApi() && event instanceof ChangeBasedEvent) { GerritSendCommandQueue.queue(new BuildStartedRestCommandJob(config, build, listener, (ChangeBasedEvent)event, stats)); diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalNotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalNotificationClaimStrategy.java index 35c20224d..5b5bb10dc 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalNotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalNotificationClaimStrategy.java @@ -49,8 +49,10 @@ public class LocalNotificationClaimStrategy extends NotificationClaimStrategy { @NonNull public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull String notificationType, + String jobIdentifier, @NonNull Runnable claimed) { // In local mode, always allow notification - no coordination needed + // jobIdentifier is ignored since there's only one instance try { claimed.run(); return ClaimResults.success(); diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java index c37ce0d81..0b8880620 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java @@ -66,25 +66,30 @@ public abstract class NotificationClaimStrategy { *

        The claim is automatically released after the action executes (success or failure), * so implementations do not need manual cleanup code.

        * + *

        Notification Claim Scoping:

        + *
          + *
        • Per-job claims (jobIdentifier != null): Each job sends its own notification + * (e.g., build-started notifications where each job notifies independently)
        • + *
        • Per-event claims (jobIdentifier == null): One notification per event + * (e.g., build-completed where feedback is aggregated across all builds)
        • + *
        + * *

        Usage Example:

        *
        -     * claimStrategy.withClaim(event, "build-completed", () -> {
        -     *     // This code runs only if claim was acquired
        -     *     // Claim is automatically released after this block
        -     *     sendNotificationToGerrit(event, buildResult);
        -     * })
        -     * .notClaimed(() -> {
        -     *     // Optional: runs if claim was not acquired
        -     *     logger.debug("Another instance is sending notification");
        -     * })
        -     * .onError((ex) -> {
        -     *     // Optional: runs if an exception occurs during processing
        -     *     logger.error("Failed to send notification", ex);
        +     * // Per-job claim (build-started)
        +     * claimStrategy.withClaim(event, "build-started", jobName, () -> {
        +     *     sendBuildStartedNotification(event, build);
        +     * });
        +     *
        +     * // Per-event claim (build-completed)
        +     * claimStrategy.withClaim(event, "build-completed", null, () -> {
        +     *     sendAggregatedBuildCompletedNotification(event, allBuilds);
              * });
              * 
        * * @param event the Gerrit event to claim notification rights for * @param notificationType the type of notification (e.g., "build-started", "build-completed") + * @param jobIdentifier optional job identifier for per-job claims, null for per-event claims * @param claimed action to execute if claim succeeds (runs with claim held, auto-released) * @return ClaimResult for chaining notClaimed/onError handlers * @see ClaimResults for shared result implementations @@ -92,14 +97,29 @@ public abstract class NotificationClaimStrategy { @NonNull public abstract ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull String notificationType, + String jobIdentifier, @NonNull Runnable claimed); /** - * Attempts to claim the right to send notification and execute the given action if successful. - * This is a convenience method that uses a default notification type. + * Convenience method without job identifier - creates per-event claim. + * + * @param event the Gerrit event to claim notification rights for + * @param notificationType the type of notification (e.g., "build-started", "build-completed") + * @param claimed action to execute if claim succeeds + * @return ClaimResult for chaining notClaimed/onError handlers + */ + @NonNull + public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, + @NonNull String notificationType, + @NonNull Runnable claimed) { + return withClaim(event, notificationType, null, claimed); + } + + /** + * Legacy convenience method that uses a default notification type. * - *

        Deprecated: Use {@link #withClaim(GerritTriggeredEvent, String, Runnable)} instead - * to properly differentiate between notification types (build-started vs build-completed).

        + *

        Deprecated: Use {@link #withClaim(GerritTriggeredEvent, String, String, Runnable)} instead + * to properly differentiate between notification types and scopes.

        * * @param event the Gerrit event to claim notification rights for * @param claimed action to execute if claim succeeds @@ -107,6 +127,6 @@ public abstract ClaimResult withClaim(@NonNull GerritTriggeredEvent event, */ @NonNull public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runnable claimed) { - return withClaim(event, "default", claimed); + return withClaim(event, "default", null, claimed); } } diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/SpecGerritTriggerHudsonTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/SpecGerritTriggerHudsonTest.java index 7f5f60ff9..d71248532 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/SpecGerritTriggerHudsonTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/SpecGerritTriggerHudsonTest.java @@ -31,6 +31,7 @@ import com.sonymobile.tools.gerrit.gerritevents.dto.events.PatchsetCreated; import com.sonyericsson.hudson.plugins.gerrit.trigger.GerritServer; import com.sonyericsson.hudson.plugins.gerrit.trigger.PluginImpl; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastInstanceProvider; import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastTestHelper; import com.sonyericsson.hudson.plugins.gerrit.trigger.config.Config; import com.sonyericsson.hudson.plugins.gerrit.trigger.events.ManualPatchsetCreated; @@ -42,6 +43,8 @@ import com.sonyericsson.hudson.plugins.gerrit.trigger.mock.TestUtils; import com.sonymobile.tools.gerrit.gerritevents.dto.events.TopicChanged; import com.sonymobile.tools.gerrit.gerritevents.mock.SshdServerMock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import hudson.model.Cause; import hudson.model.FreeStyleBuild; @@ -75,7 +78,7 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.fail; -//CS IGNORE MagicNumber FOR NEXT 920 LINES. REASON: Testdata. +//CS IGNORE MagicNumber FOR NEXT 960 LINES. REASON: Testdata. /** * Some full run-through tests from trigger to build finished. @@ -84,6 +87,8 @@ */ public class SpecGerritTriggerHudsonTest { + private static final Logger logger = LoggerFactory.getLogger(SpecGerritTriggerHudsonTest.class); + /** * An instance of Jenkins Rule. */ @@ -112,6 +117,8 @@ public class SpecGerritTriggerHudsonTest { */ @Before public void setUp() throws Exception { + // Clear Hazelcast maps before each test to prevent test pollution + clearHazelcastMaps(); SshdServerMock.generateKeyPair(); serverMock = new SshdServerMock(); @@ -126,6 +133,39 @@ public void setUp() throws Exception { SshdServerMock.configureFor(sshd, gerritServer, true); } + /** + * Clear Hazelcast notification and event claim maps before each test. + * This prevents test pollution when running multiple test methods in Hazelcast mode, + * since many tests use the same mock event IDs. + */ + private void clearHazelcastMaps() { + // Only clear if Hazelcast is initialized + if (HazelcastInstanceProvider.isInitialized()) { + try { + com.hazelcast.core.HazelcastInstance instance = HazelcastInstanceProvider.getInstance(); + + // Clear notification flags + com.hazelcast.map.IMap notificationFlags = + instance.getMap("gerrit-trigger-notification-flags"); + int notificationCount = notificationFlags.size(); + notificationFlags.clear(); + + // Clear event claims + com.hazelcast.map.IMap eventClaims = + instance.getMap("gerrit-trigger-event-claims"); + int eventClaimCount = eventClaims.size(); + eventClaims.clear(); + + if (notificationCount > 0 || eventClaimCount > 0) { + logger.info("Cleared {} notification claim(s) and {} event claim(s) before test", + notificationCount, eventClaimCount); + } + } catch (Exception e) { + logger.warn("Failed to clear Hazelcast maps in SpecGerritTriggerHudsonTest", e); + } + } + } + /** * Runs after test method. * @@ -376,6 +416,9 @@ public void testDoubleTriggeredBuildsOfDifferentChange() throws Exception { System.out.println("Build Started"); PatchsetCreated patchsetCreated = Setup.createPatchsetCreated(); patchsetCreated.getChange().setNumber("2000"); + // Must also set unique Change-Id for different change number + // (in real Gerrit, each change has its own unique Change-Id) + patchsetCreated.getChange().setId("Ibbddeeff987654321"); gerritServer.triggerEvent(patchsetCreated); System.out.println("PatchSet 2 created"); From abab9dfbb00b1fe2dfa553842f60285e0bf89bf3 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Wed, 27 May 2026 16:14:20 +0200 Subject: [PATCH 29/87] Fixing initialization problem with client and event duplication issues --- .../plugins/gerrit/trigger/PluginImpl.java | 82 +++++++++++++++++ .../hazelcast/HazelcastConfig.java | 90 ++++++++++++++++++- .../hazelcast/HazelcastManager.java | 79 +++++++++++----- 3 files changed, 226 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java index 1ead26d54..005b0a153 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java @@ -68,6 +68,7 @@ import java.util.LinkedList; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; import jenkins.model.Jenkins; @@ -138,6 +139,28 @@ public class PluginImpl extends GlobalConfiguration { */ public static final String TEST_SSH_KEYFILE_LOCATION_PROPERTY = PluginImpl.class.getName() + "_test_ssh_key_file"; + /** + * System property: minimum number of Hazelcast cluster members expected before connecting to Gerrit. + * Default 1 disables the wait (single-instance or local mode). + * Set to 2 or more in HA/HS deployments to prevent the startup race where events arrive before + * the distributed claim map is shared across replicas. + */ + public static final String HAZELCAST_EXPECTED_MEMBERS_PROPERTY = + "gerrit.trigger.coordination.hazelcast.expected.members"; + + /** + * System property: maximum seconds to wait for Hazelcast cluster formation. + * Default: 30 seconds. + */ + public static final String HAZELCAST_CLUSTER_WAIT_TIMEOUT_PROPERTY = + "gerrit.trigger.coordination.hazelcast.cluster.wait.timeout.seconds"; + + private static final int DEFAULT_EXPECTED_CLUSTER_MEMBERS = 1; + + private static final int DEFAULT_CLUSTER_WAIT_TIMEOUT_SECONDS = 30; + + private static final long CLUSTER_WAIT_POLL_INTERVAL_MS = 500L; + /** * Gets api. * @return the api. @@ -586,6 +609,11 @@ public void start() { // because provider.isAvailable() may check if resources are initialized initializeCoordinationProviders(); + // Wait for Hazelcast cluster to reach the expected member count before connecting to Gerrit. + // Without this, events received during the startup window bypass the distributed claim mechanism + // and cause duplicate builds across replicas. + waitForHazelcastCluster(); + GerritSendCommandQueue.initialize(pluginConfig); gerritEventManager = new JenkinsAwareGerritHandler(pluginConfig.getNumberOfReceivingWorkerThreads()); for (GerritServer s : servers) { @@ -594,6 +622,60 @@ public void start() { active = true; } + /** + * Waits for the Hazelcast cluster to reach the expected number of members before + * Gerrit server connections are opened. + *

        + * In HA/HS deployments each replica has its own SSH connection to Gerrit and therefore + * receives every event independently. Without this guard, a replica that starts while + * the Hazelcast cluster is still forming will process events against its own single-member + * IMap, making the distributed claim invisible to other replicas and causing duplicate builds. + *

        + * The wait is skipped when Hazelcast is not active (local mode) or when + * {@link #HAZELCAST_EXPECTED_MEMBERS_PROPERTY} is 1 (the default). + */ + private void waitForHazelcastCluster() { + com.hazelcast.core.HazelcastInstance hz = + com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastInstanceProvider + .getInstance(); + if (hz == null) { + return; + } + + int expectedMembers = Integer.getInteger(HAZELCAST_EXPECTED_MEMBERS_PROPERTY, + DEFAULT_EXPECTED_CLUSTER_MEMBERS); + if (expectedMembers <= DEFAULT_EXPECTED_CLUSTER_MEMBERS) { + return; + } + + int timeoutSeconds = Integer.getInteger(HAZELCAST_CLUSTER_WAIT_TIMEOUT_PROPERTY, + DEFAULT_CLUSTER_WAIT_TIMEOUT_SECONDS); + logger.info("Waiting for Hazelcast cluster to form ({} expected members, timeout: {}s)...", + expectedMembers, timeoutSeconds); + + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(timeoutSeconds); + int currentSize = hz.getCluster().getMembers().size(); + while (currentSize < expectedMembers && System.currentTimeMillis() < deadline) { + logger.debug("Hazelcast cluster has {} of {} expected members, waiting...", + currentSize, expectedMembers); + try { + Thread.sleep(CLUSTER_WAIT_POLL_INTERVAL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.warn("Interrupted while waiting for Hazelcast cluster formation"); + return; + } + currentSize = hz.getCluster().getMembers().size(); + } + + if (currentSize >= expectedMembers) { + logger.info("Hazelcast cluster ready: {} member(s)", currentSize); + } else { + logger.warn("Timed out waiting for Hazelcast cluster ({}/{} members). " + + "Proceeding anyway - duplicate builds may occur.", currentSize, expectedMembers); + } + } + /** * Initialize the active coordination mode provider. *

        diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java index dda12d944..53803ef91 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java @@ -23,6 +23,7 @@ */ package com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; +import com.hazelcast.client.config.ClientConfig; import com.hazelcast.config.Config; import com.hazelcast.config.JoinConfig; import com.hazelcast.config.NetworkConfig; @@ -107,6 +108,36 @@ public final class HazelcastConfig { */ public static final String TCP_MEMBERS_PROPERTY = "gerrit.trigger.coordination.hazelcast.tcp.members"; + /** + * System property to set Hazelcast instance mode. + * Values: "member" (default, creates an embedded cluster member) or + * "client" (connects to an existing Hazelcast cluster, e.g. a sidecar container). + * Client mode is recommended when a Hazelcast sidecar is already present in the pod, + * as it reuses the existing cross-pod cluster instead of creating a new one. + */ + public static final String INSTANCE_MODE_PROPERTY = "gerrit.trigger.coordination.hazelcast.instance.mode"; + + /** + * System property to specify addresses for Hazelcast client mode (comma-separated host:port). + * Default: "localhost:5702" — assumes a Hazelcast sidecar listening on port 5702 in the same pod. + * Example: "localhost:5702" + */ + public static final String CLIENT_ADDRESSES_PROPERTY = + "gerrit.trigger.coordination.hazelcast.client.addresses"; + + /** + * System property to specify the cluster name for Hazelcast client mode. + * Must match the cluster name of the target Hazelcast cluster. + * Default: {@link #DEFAULT_CLUSTER_NAME}. + */ + public static final String CLIENT_CLUSTER_NAME_PROPERTY = + "gerrit.trigger.coordination.hazelcast.client.cluster.name"; + + /** + * Default address for Hazelcast client mode: local sidecar on port 5702. + */ + public static final String DEFAULT_CLIENT_ADDRESS = "localhost:5702"; + /** * Private constructor to prevent instantiation. */ @@ -186,12 +217,14 @@ private static void configureNetwork(Config config) { if ("multicast".equalsIgnoreCase(discoveryMode)) { // Multicast mode - primarily for testing configureMulticastDiscovery(joinConfig); - } else if ("kubernetes".equalsIgnoreCase(discoveryMode) || isKubernetesEnvironment()) { + } else if ("kubernetes".equalsIgnoreCase(discoveryMode)) { + // Explicit kubernetes mode - always use Kubernetes discovery regardless of environment configureKubernetesDiscovery(joinConfig, port); } else if ("tcp".equalsIgnoreCase(discoveryMode) || hasTcpMembersConfigured()) { + // Explicit tcp mode, or TCP members configured - always use TCP discovery configureTcpDiscovery(joinConfig, port); } else { - // Fallback: Try Kubernetes, then TCP + // Auto-detect: no explicit mode set, try Kubernetes first, then TCP logger.info("Auto-detecting discovery mechanism..."); if (isKubernetesEnvironment()) { configureKubernetesDiscovery(joinConfig, port); @@ -295,6 +328,59 @@ private static void configureMulticastDiscovery(JoinConfig joinConfig) { joinConfig.getAzureConfig().setEnabled(false); } + /** + * Returns true if Hazelcast client mode is configured. + *

        + * In client mode the plugin connects to an existing Hazelcast cluster (e.g. a sidecar) + * instead of creating its own embedded member. This avoids port conflicts and reuses + * the cross-pod cluster that the sidecar already maintains. + * + * @return true when {@link #INSTANCE_MODE_PROPERTY} is set to "client" + */ + public static boolean isClientMode() { + return "client".equalsIgnoreCase(System.getProperty(INSTANCE_MODE_PROPERTY, "member")); + } + + /** + * Creates a Hazelcast client configuration to connect to an existing cluster. + *

        + * Used when {@link #isClientMode()} is true. The client connects to the addresses + * specified by {@link #CLIENT_ADDRESSES_PROPERTY} (default: {@link #DEFAULT_CLIENT_ADDRESS}) + * and joins the cluster whose name matches {@link #CLIENT_CLUSTER_NAME_PROPERTY}. + * + * @return configured Hazelcast ClientConfig + */ + public static ClientConfig createClientConfig() { + ClientConfig config = new ClientConfig(); + + String clusterName = System.getProperty(CLIENT_CLUSTER_NAME_PROPERTY, DEFAULT_CLUSTER_NAME); + config.setClusterName(clusterName); + logger.info("Hazelcast client cluster name: {}", clusterName); + + String addressesProperty = System.getProperty(CLIENT_ADDRESSES_PROPERTY, DEFAULT_CLIENT_ADDRESS); + logger.info("Hazelcast client addresses: {}", addressesProperty); + + for (String address : addressesProperty.split(",")) { + String trimmed = address.trim(); + if (!trimmed.isEmpty()) { + config.getNetworkConfig().addAddress(trimmed); + } + } + + config.setProperty("hazelcast.logging.type", "slf4j"); + + // Register Compact Serializers — must match member config for cross-JVM deserialization + config.getSerializationConfig() + .getCompactSerializationConfig() + .addSerializer(new EventClaimSerializer()) + .addSerializer(new EntryDataSerializer()) + .addSerializer(new MemoryImprintDataSerializer()); + logger.debug("Registered Compact Serializers for EventClaim, EntryData, and MemoryImprintData"); + + logger.info("Hazelcast client configuration created for cluster: {}", clusterName); + return config; + } + /** * Checks if running in Kubernetes environment. * diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java index 232473afe..ec968fdcf 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java @@ -23,6 +23,7 @@ */ package com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; +import com.hazelcast.client.HazelcastClient; import com.hazelcast.core.Hazelcast; import com.hazelcast.core.HazelcastInstance; import org.slf4j.Logger; @@ -51,14 +52,16 @@ private HazelcastManager() { } /** - * Initializes Hazelcast embedded member. + * Initializes Hazelcast in the mode determined by {@link HazelcastConfig#isClientMode()}. *

        - * Creates a Hazelcast member in the Jenkins JVM with configuration from - * {@link HazelcastConfig#createConfig()}. + * In member mode (default) an embedded Hazelcast member is created that forms + * its own cluster with other replicas. In client mode a lightweight Hazelcast + * client connects to an existing cluster (e.g. a sidecar container on the same pod), + * reusing its cross-pod topology without starting a new member. *

        - * This method is idempotent - calling it multiple times returns the existing instance. + * This method is idempotent — calling it multiple times returns the existing instance. * - * @return the Hazelcast instance + * @return the Hazelcast instance (member or client) * @throws RuntimeException if initialization fails */ public static HazelcastInstance initialize() { @@ -72,26 +75,15 @@ public static HazelcastInstance initialize() { } try { - logger.info("Initializing Hazelcast embedded member..."); - - // Create Hazelcast configuration - com.hazelcast.config.Config config = HazelcastConfig.createConfig(); - - // Create Hazelcast instance - HazelcastInstance hazelcastInstance = Hazelcast.newHazelcastInstance(config); + HazelcastInstance hazelcastInstance; + if (HazelcastConfig.isClientMode()) { + hazelcastInstance = initializeClient(); + } else { + hazelcastInstance = initializeMember(); + } - // Register with provider (for backward compatibility with helper methods) HazelcastInstanceProvider.setInstance(hazelcastInstance); - initialized = true; - - // Log cluster information - int clusterSize = hazelcastInstance.getCluster().getMembers().size(); - logger.info("Hazelcast embedded member initialized. Cluster: {}, Instance: {}, Members: {}", - config.getClusterName(), - hazelcastInstance.getName(), - clusterSize); - return hazelcastInstance; } catch (Exception e) { @@ -102,6 +94,38 @@ public static HazelcastInstance initialize() { } } + /** + * Creates a Hazelcast embedded member using {@link HazelcastConfig#createConfig()}. + * + * @return the initialized Hazelcast member instance + */ + private static HazelcastInstance initializeMember() { + logger.info("Initializing Hazelcast embedded member..."); + com.hazelcast.config.Config config = HazelcastConfig.createConfig(); + HazelcastInstance hz = Hazelcast.newHazelcastInstance(config); + logger.info("Hazelcast embedded member initialized. Cluster: {}, Instance: {}, Members: {}", + config.getClusterName(), hz.getName(), hz.getCluster().getMembers().size()); + return hz; + } + + /** + * Creates a Hazelcast client using {@link HazelcastConfig#createClientConfig()}. + *

        + * The client connects to an existing cluster (e.g. a Hazelcast sidecar on the same pod) + * and accesses its distributed maps. No new cluster member is created, so there is no + * port conflict with the sidecar and no need for cross-pod member discovery. + * + * @return the initialized Hazelcast client instance + */ + private static HazelcastInstance initializeClient() { + logger.info("Initializing Hazelcast client (connecting to existing cluster)..."); + com.hazelcast.client.config.ClientConfig config = HazelcastConfig.createClientConfig(); + HazelcastInstance hz = HazelcastClient.newHazelcastClient(config); + logger.info("Hazelcast client initialized. Cluster: {}, Members: {}", + config.getClusterName(), hz.getCluster().getMembers().size()); + return hz; + } + /** * Shuts down Hazelcast gracefully. *

        @@ -196,9 +220,18 @@ public static String getStatus() { try { int clusterSize = instance.getCluster().getMembers().size(); - String clusterName = instance.getConfig().getClusterName(); String instanceName = instance.getName(); + if (HazelcastConfig.isClientMode()) { + // getConfig() is not supported on Hazelcast clients + String clusterName = System.getProperty( + HazelcastConfig.CLIENT_CLUSTER_NAME_PROPERTY, + HazelcastConfig.DEFAULT_CLUSTER_NAME); + return String.format("Hazelcast Client: Running | Cluster: %s | Members: %d", + clusterName, clusterSize); + } + + String clusterName = instance.getConfig().getClusterName(); return String.format("Hazelcast: Running | Cluster: %s | Instance: %s | Members: %d", clusterName, instanceName, clusterSize); } catch (Exception e) { From 323faea928a2dcf3253d1e38f4a1cd4440feb064 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Thu, 28 May 2026 11:59:22 +0200 Subject: [PATCH 30/87] Fixing gerrit feedback problems --- .../HazelcastBuildMemoryStorage.java | 267 +++++++++++++++--- 1 file changed, 231 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 01d137c21..eb0a984a6 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -281,14 +281,35 @@ public synchronized void triggered(@NonNull GerritTriggeredEvent event, @NonNull String projectFullName = project.getFullName(); String eventJson = serializeEvent(event); - // ATOMIC OPERATION - Executes on partition owner, prevents race conditions + // ATOMIC OPERATION - Distributed lock ensures only one replica modifies this entry at a time. // This is critical when multiple projects are triggered by the same event simultaneously. // Without atomic operations, concurrent threads can overwrite each other's entries, // causing some project entries to be lost from BuildMemory (which breaks cancellation logic). + // Note: EntryProcessor is NOT used here because in client mode the processor class would need + // to exist on the Hazelcast sidecar member's classpath, causing ClassNotFoundException. + map.lock(key); try { - Boolean wasNew = map.executeOnKey(key, new TriggeredProcessor(projectFullName, eventJson)); - - if (wasNew) { + MemoryImprintData data = map.get(key); + if (data == null) { + data = new MemoryImprintData(); + data.setEventJson(eventJson); + } + boolean found = false; + if (data.getEntries() != null) { + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + found = true; + break; + } + } + } + if (!found) { + EntryData newEntry = new EntryData(); + newEntry.setProjectFullName(projectFullName); + data.addEntry(newEntry); + } + map.put(key, data); + if (!found) { logger.trace("Triggered event stored in distributed memory: {} for project: {}", key, projectFullName); } else { logger.trace("Project {} already triggered for event: {}", projectFullName, key); @@ -296,6 +317,8 @@ public synchronized void triggered(@NonNull GerritTriggeredEvent event, @NonNull } catch (Exception e) { logger.error("Failed to store triggered event in distributed memory for project: {} event: {}", projectFullName, key, e); + } finally { + map.unlock(key); } } @@ -311,10 +334,33 @@ public synchronized void started(@NonNull GerritTriggeredEvent event, @NonNull R String projectFullName = build.getParent().getFullName(); String buildId = build.getId(); - // ATOMIC OPERATION - Executes on partition owner, prevents race conditions + // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). + long startedTimestamp = System.currentTimeMillis(); + map.lock(key); try { - Boolean found = map.executeOnKey(key, new BuildStartedProcessor(projectFullName, buildId)); - + MemoryImprintData data = map.get(key); + if (data == null) { + data = new MemoryImprintData(); + } + boolean found = false; + if (data.getEntries() != null) { + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + entryData.setBuildId(buildId); + entryData.setStartedTimestamp(startedTimestamp); + found = true; + break; + } + } + } + if (!found) { + EntryData newEntry = new EntryData(); + newEntry.setProjectFullName(projectFullName); + newEntry.setBuildId(buildId); + newEntry.setStartedTimestamp(startedTimestamp); + data.addEntry(newEntry); + } + map.put(key, data); if (!found) { logger.warn("Build started without being registered first (distributed mode)."); } @@ -322,6 +368,8 @@ public synchronized void started(@NonNull GerritTriggeredEvent event, @NonNull R } catch (Exception e) { logger.error("Failed to mark build started in distributed memory: project={}, build={}, event={}", projectFullName, buildId, key, e); + } finally { + map.unlock(key); } } @@ -337,10 +385,37 @@ public synchronized void completed(@NonNull GerritTriggeredEvent event, @NonNull String projectFullName = build.getParent().getFullName(); String buildId = build.getId(); - // ATOMIC OPERATION - Executes on partition owner, prevents race conditions + // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). + long completedTimestamp = System.currentTimeMillis(); + map.lock(key); try { - Boolean found = map.executeOnKey(key, new BuildCompletedProcessor(projectFullName, buildId)); - + MemoryImprintData data = map.get(key); + if (data == null) { + data = new MemoryImprintData(); + } + boolean found = false; + if (data.getEntries() != null) { + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + if (entryData.getBuildId() == null) { + entryData.setBuildId(buildId); + } + entryData.setCompletedTimestamp(completedTimestamp); + entryData.setBuildCompleted(true); + found = true; + break; + } + } + } + if (!found) { + EntryData newEntry = new EntryData(); + newEntry.setProjectFullName(projectFullName); + newEntry.setBuildId(buildId); + newEntry.setCompletedTimestamp(completedTimestamp); + newEntry.setBuildCompleted(true); + data.addEntry(newEntry); + } + map.put(key, data); if (!found) { logger.debug("Build completed without being registered first (distributed mode)."); } @@ -348,6 +423,8 @@ public synchronized void completed(@NonNull GerritTriggeredEvent event, @NonNull } catch (Exception e) { logger.error("Failed to mark build completed in distributed memory: project={}, build={}, event={}", projectFullName, buildId, key, e); + } finally { + map.unlock(key); } } @@ -364,13 +441,48 @@ public synchronized void retriggered(@NonNull GerritTriggeredEvent event, @NonNu String projectFullName = project.getFullName(); String eventJson = serializeEvent(event); - // ATOMIC OPERATION - Executes on partition owner, prevents race conditions + // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). + map.lock(key); try { - map.executeOnKey(key, new RetriggeredProcessor(projectFullName, eventJson, otherBuilds)); + MemoryImprintData data = map.get(key); + if (data == null) { + data = new MemoryImprintData(); + data.setEventJson(eventJson); + if (otherBuilds != null) { + for (Run otherBuild : otherBuilds) { + EntryData entryData = new EntryData(); + entryData.setProjectFullName(otherBuild.getParent().getFullName()); + entryData.setBuildId(otherBuild.getId()); + entryData.setBuildCompleted(!otherBuild.isBuilding()); + data.addEntry(entryData); + } + } + } + boolean found = false; + if (data.getEntries() != null) { + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + entryData.setBuildId(null); + entryData.setBuildCompleted(false); + entryData.setStartedTimestamp(null); + entryData.setCompletedTimestamp(null); + found = true; + break; + } + } + } + if (!found) { + EntryData newEntry = new EntryData(); + newEntry.setProjectFullName(projectFullName); + data.addEntry(newEntry); + } + map.put(key, data); logger.trace("Retriggered event stored in distributed memory: {}", key); } catch (Exception e) { logger.error("Failed to record retriggered in distributed memory: project={}, event={}", projectFullName, key, e); + } finally { + map.unlock(key); } } @@ -385,10 +497,37 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull BuildMemoryKey key = new BuildMemoryKey(event); String projectFullName = project.getFullName(); - // ATOMIC OPERATION - Executes on partition owner, prevents race conditions + // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). + long cancelledTimestamp = System.currentTimeMillis(); + map.lock(key); try { - Boolean found = map.executeOnKey(key, new BuildCancelledProcessor(projectFullName)); - + MemoryImprintData data = map.get(key); + if (data == null) { + data = new MemoryImprintData(); + } + boolean found = false; + if (data.getEntries() != null) { + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + entryData.setCancelled(true); + entryData.setCancelling(false); + entryData.setCompletedTimestamp(cancelledTimestamp); + entryData.setBuildCompleted(true); + found = true; + break; + } + } + } + if (!found) { + EntryData newEntry = new EntryData(); + newEntry.setProjectFullName(projectFullName); + newEntry.setCancelled(true); + newEntry.setCancelling(false); + newEntry.setCompletedTimestamp(cancelledTimestamp); + newEntry.setBuildCompleted(true); + data.addEntry(newEntry); + } + map.put(key, data); if (!found) { logger.debug("Build cancelled without being registered first (distributed mode)."); } @@ -396,6 +535,8 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull } catch (Exception e) { logger.error("Failed to mark cancelled in distributed memory: project={}, event={}", projectFullName, key, e); + } finally { + map.unlock(key); } } @@ -410,13 +551,30 @@ public synchronized void setCancelling(@NonNull GerritTriggeredEvent event, @Non BuildMemoryKey key = new BuildMemoryKey(event); String projectFullName = project.getFullName(); - // ATOMIC OPERATION - Executes on partition owner, prevents race conditions + // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). + map.lock(key); try { - map.executeOnKey(key, new SetCancellingProcessor(projectFullName)); + MemoryImprintData data = map.get(key); + if (data != null && data.getEntries() != null) { + boolean updated = false; + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + if (!entryData.isBuildCompleted() && !entryData.isCancelling() && !entryData.isCancelled()) { + entryData.setCancelling(true); + updated = true; + } + } + } + if (updated) { + map.put(key, data); + } + } logger.trace("Cancelling flag set in distributed memory for event: {}", key); } catch (Exception e) { logger.error("Failed to set cancelling flag in distributed memory: project={}, event={}", projectFullName, key, e); + } finally { + map.unlock(key); } } @@ -441,28 +599,36 @@ public synchronized void removeProject(@NonNull Job project) { return; } - // ATOMIC OPERATION - Process each entry atomically to prevent race conditions + // ATOMIC OPERATION - Distributed lock per key. EntryProcessor not used (ClassNotFoundException in client mode). // Collect keys first to avoid ConcurrentModificationException java.util.Set keys = new java.util.HashSet<>(map.keySet()); for (BuildMemoryKey key : keys) { + map.lock(key); try { - // Execute processor atomically on partition owner - Boolean shouldDelete = map.executeOnKey(key, new RemoveProjectProcessor(projectFullName)); - - if (shouldDelete != null && shouldDelete) { - // MemoryImprintData is now empty - delete the map entry - map.delete(key); - logger.trace("Removed empty entry for project {} from distributed memory: {}", - projectFullName, key); - } else if (shouldDelete != null) { - logger.trace("Removed project {} from distributed memory entry: {}", - projectFullName, key); + MemoryImprintData data = map.get(key); + if (data == null || data.getEntries() == null) { + continue; + } + boolean removed = data.getEntries().removeIf( + entryData -> projectFullName.equals(entryData.getProjectFullName())); + if (removed) { + if (data.getEntries().isEmpty()) { + map.delete(key); + logger.trace("Removed empty entry for project {} from distributed memory: {}", + projectFullName, key); + } else { + map.put(key, data); + logger.trace("Removed project {} from distributed memory entry: {}", + projectFullName, key); + } } } catch (Exception e) { logger.error("Failed to remove project from distributed memory entry: project={}, key={}", projectFullName, key, e); // Continue processing other keys + } finally { + map.unlock(key); } } } @@ -567,11 +733,24 @@ public void setEntryCustomUrl(@NonNull GerritTriggeredEvent event, @NonNull Run BuildMemoryKey key = new BuildMemoryKey(event); String projectFullName = r.getParent().getFullName(); - // ATOMIC OPERATION - Executes on partition owner, prevents race conditions + // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). + map.lock(key); try { - Boolean found = map.executeOnKey(key, new SetCustomUrlProcessor(projectFullName, customUrl)); - + MemoryImprintData data = map.get(key); + if (data == null || data.getEntries() == null) { + logger.warn("Could not set custom URL - event not found: {}", event); + return; + } + boolean found = false; + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + entryData.setCustomUrl(customUrl); + found = true; + break; + } + } if (found) { + map.put(key, data); logger.trace("Recording custom URL for {}: {}", event, customUrl); } else { logger.warn("Could not set custom URL - event not found: {}", event); @@ -579,6 +758,8 @@ public void setEntryCustomUrl(@NonNull GerritTriggeredEvent event, @NonNull Run } catch (Exception e) { logger.error("Failed to set custom URL in distributed memory: project={}, event={}, url={}", projectFullName, key, customUrl, e); + } finally { + map.unlock(key); } } @@ -594,12 +775,24 @@ public void setEntryUnsuccessfulMessage(@NonNull GerritTriggeredEvent event, @No BuildMemoryKey key = new BuildMemoryKey(event); String projectFullName = r.getParent().getFullName(); - // ATOMIC OPERATION - Executes on partition owner, prevents race conditions + // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). + map.lock(key); try { - Boolean found = map.executeOnKey(key, - new SetUnsuccessfulMessageProcessor(projectFullName, unsuccessfulMessage)); - + MemoryImprintData data = map.get(key); + if (data == null || data.getEntries() == null) { + logger.warn("Could not set unsuccessful message - event not found: {}", event); + return; + } + boolean found = false; + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + entryData.setUnsuccessfulMessage(unsuccessfulMessage); + found = true; + break; + } + } if (found) { + map.put(key, data); logger.trace("Recording unsuccessful message for {}: {}", event, unsuccessfulMessage); } else { logger.warn("Could not set unsuccessful message - event not found: {}", event); @@ -607,6 +800,8 @@ public void setEntryUnsuccessfulMessage(@NonNull GerritTriggeredEvent event, @No } catch (Exception e) { logger.error("Failed to set unsuccessful message in distributed memory: project={}, event={}, message={}", projectFullName, key, unsuccessfulMessage, e); + } finally { + map.unlock(key); } } From f963e01c54290e2354c502ae8006b2d2801d86cb Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Thu, 28 May 2026 18:58:10 +0200 Subject: [PATCH 31/87] Fixing problem with the gerrit feedback --- .../HazelcastBuildMemoryStorage.java | 71 +++++++++++++++++-- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index eb0a984a6..d2bbb6c53 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -221,9 +221,19 @@ private MemoryImprint reconstructMemoryImprint(GerritTriggeredEvent event, Memor String projectFullName = entryData.getProjectFullName(); Job project = jenkins.getItemByFullName(projectFullName, Job.class); + logger.info("[HZ-DIAG] reconstruct: project='{}' found={} buildId='{}' completed={} cancelled={}", + projectFullName, project != null, entryData.getBuildId(), + entryData.isBuildCompleted(), entryData.isCancelled()); + if (project != null) { if (entryData.getBuildId() != null) { Run build = project.getBuild(entryData.getBuildId()); + String buildResult = "N/A"; + if (build != null) { + buildResult = String.valueOf(build.getResult()); + } + logger.info("[HZ-DIAG] reconstruct: project.getBuild('{}') = {} result={}", + entryData.getBuildId(), build != null, buildResult); if (build != null) { imprint.set(project, build, entryData.isBuildCompleted()); } else { @@ -263,6 +273,12 @@ public synchronized MemoryImprint getMemoryImprint(@NonNull GerritTriggeredEvent BuildMemoryKey key = new BuildMemoryKey(event); MemoryImprintData data = map.get(key); + int entryCount = -1; + if (data != null && data.getEntries() != null) { + entryCount = data.getEntries().size(); + } + logger.info("[HZ-DIAG] getMemoryImprint key={} dataFound={} entries={}", + key, data != null, entryCount); if (data != null) { return reconstructMemoryImprint(event, data); } @@ -281,6 +297,8 @@ public synchronized void triggered(@NonNull GerritTriggeredEvent event, @NonNull String projectFullName = project.getFullName(); String eventJson = serializeEvent(event); + logger.info("[HZ-DIAG] triggered key={} project={}", key, projectFullName); + // ATOMIC OPERATION - Distributed lock ensures only one replica modifies this entry at a time. // This is critical when multiple projects are triggered by the same event simultaneously. // Without atomic operations, concurrent threads can overwrite each other's entries, @@ -309,6 +327,12 @@ public synchronized void triggered(@NonNull GerritTriggeredEvent event, @NonNull data.addEntry(newEntry); } map.put(key, data); + int triggeredEntries = 0; + if (data.getEntries() != null) { + triggeredEntries = data.getEntries().size(); + } + logger.info("[HZ-DIAG] triggered stored: key={} found={} totalEntries={}", + key, found, triggeredEntries); if (!found) { logger.trace("Triggered event stored in distributed memory: {} for project: {}", key, projectFullName); } else { @@ -385,6 +409,9 @@ public synchronized void completed(@NonNull GerritTriggeredEvent event, @NonNull String projectFullName = build.getParent().getFullName(); String buildId = build.getId(); + logger.info("[HZ-DIAG] completed key={} project={} buildId={} result={}", + key, projectFullName, buildId, build.getResult()); + // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). long completedTimestamp = System.currentTimeMillis(); map.lock(key); @@ -416,6 +443,12 @@ public synchronized void completed(@NonNull GerritTriggeredEvent event, @NonNull data.addEntry(newEntry); } map.put(key, data); + int completedEntries = 0; + if (data.getEntries() != null) { + completedEntries = data.getEntries().size(); + } + logger.info("[HZ-DIAG] completed stored: key={} found={} totalEntries={} buildCompleted={}", + key, found, completedEntries, true); if (!found) { logger.debug("Build completed without being registered first (distributed mode)."); } @@ -506,14 +539,28 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull data = new MemoryImprintData(); } boolean found = false; + boolean modified = false; if (data.getEntries() != null) { for (EntryData entryData : data.getEntries()) { if (projectFullName.equals(entryData.getProjectFullName())) { - entryData.setCancelled(true); - entryData.setCancelling(false); - entryData.setCompletedTimestamp(cancelledTimestamp); - entryData.setBuildCompleted(true); found = true; + // Only mark as cancelled if the build never started (buildId is null) + // OR was actively being cancelled by a new patchset (cancelling flag was set). + // If buildId is already set but cancelling is false, this is a cross-replica + // queue deduplication: the actual build is running on another replica. + // Writing cancelled=true in that case poisons the shared map and causes + // premature "No Builds Executed" feedback. + if (entryData.getBuildId() == null || entryData.isCancelling()) { + entryData.setCancelled(true); + entryData.setCancelling(false); + entryData.setCompletedTimestamp(cancelledTimestamp); + entryData.setBuildCompleted(true); + modified = true; + } else { + logger.debug("Skipping cancelled() for project={} event={}: " + + "buildId already set by another replica (cross-replica queue dedup)", + projectFullName, key); + } break; } } @@ -526,8 +573,11 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull newEntry.setCompletedTimestamp(cancelledTimestamp); newEntry.setBuildCompleted(true); data.addEntry(newEntry); + modified = true; + } + if (modified) { + map.put(key, data); } - map.put(key, data); if (!found) { logger.debug("Build cancelled without being registered first (distributed mode)."); } @@ -636,7 +686,16 @@ public synchronized void removeProject(@NonNull Job project) { @Override public synchronized boolean isAllBuildsCompleted(@NonNull GerritTriggeredEvent event) { MemoryImprint imprint = getMemoryImprint(event); - return imprint != null && imprint.isAllBuildsCompleted(); + boolean result = imprint != null && imprint.isAllBuildsCompleted(); + logger.info("[HZ-DIAG] isAllBuildsCompleted: imprintNull={} result={}", + imprint == null, result); + if (imprint != null) { + logger.info("[HZ-DIAG] isAllBuildsCompleted: wereAllBuildsSuccessful={} wereAnyBuildsFailed={}" + + " wereAllBuildsNotBuilt={}", + imprint.wereAllBuildsSuccessful(), imprint.wereAnyBuildsFailed(), + imprint.wereAllBuildsNotBuilt()); + } + return result; } @Override From babf20eab1009d952abe39d60c231d25af7f792c Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Mon, 1 Jun 2026 11:49:15 +0200 Subject: [PATCH 32/87] HA/HS: Implement cross-replica build abortion for new patchsets (HZ-004) When PS2 arrives while PS1 is building on another replica, abort PS1 via a distributed abort inbox (IMap + EntryAddedListener), since executeOnMember() targets the Hazelcast sidecar JVM in CLIENT mode. Also fixes premature forget() when QueueLoadBalancer moves queue items, and abstracts load-balancing detection into QueueCancellationStrategy SPI. --- .../coordination/CoordinationModeFactory.java | 26 ++ .../LocalCoordinationProvider.java | 13 + .../HazelcastBuildMemoryStorage.java | 244 +++++++++++------- .../HazelcastCoordinationProvider.java | 15 ++ .../HazelcastQueueCancellationStrategy.java | 76 ++++++ .../LocalQueueCancellationStrategy.java | 49 ++++ .../gerritnotifier/model/BuildMemory.java | 4 +- .../trigger/hudsontrigger/EventListener.java | 7 +- .../hudsontrigger/GerritQueueListener.java | 4 + .../trigger/spi/CoordinationModeProvider.java | 20 ++ .../spi/QueueCancellationStrategy.java | 54 ++++ 11 files changed, 416 insertions(+), 96 deletions(-) create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastQueueCancellationStrategy.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalQueueCancellationStrategy.java create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/QueueCancellationStrategy.java diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/CoordinationModeFactory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/CoordinationModeFactory.java index 37741bac2..b13cd784b 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/CoordinationModeFactory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/CoordinationModeFactory.java @@ -27,9 +27,11 @@ import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.EventClaimStrategy; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.NotificationClaimStrategy; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.QueueCancellationStrategy; import com.sonyericsson.hudson.plugins.gerrit.trigger.storage.LocalBuildMemoryStorage; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.LocalEventClaimStrategy; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.LocalNotificationClaimStrategy; +import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.LocalQueueCancellationStrategy; import hudson.Extension; import hudson.ExtensionList; import org.slf4j.Logger; @@ -116,6 +118,12 @@ public class CoordinationModeFactory { */ private volatile EventClaimStrategy eventClaimStrategy; + /** + * The queue cancellation strategy instance, lazily initialized. + * Instance field - managed by Jenkins lifecycle, not static. + */ + private volatile QueueCancellationStrategy queueCancellationStrategy; + /** * Constructor - called by Jenkins once per Jenkins instance. * Public constructor allows Jenkins to instantiate via @Extension mechanism. @@ -209,6 +217,21 @@ public EventClaimStrategy getEventClaimStrategy() { return eventClaimStrategy; } + /** + * Gets the QueueCancellationStrategy instance for the current coordination mode. + * + *

        Uses double-checked locking for thread-safe lazy initialization. + * The mode is discovered and instances are created on first access.

        + * + * @return the queue cancellation strategy implementation + * @throws IllegalStateException if no available mode provider is found + */ + @NonNull + public QueueCancellationStrategy getQueueCancellationStrategy() { + ensureInitialized(); + return queueCancellationStrategy; + } + /** * Ensures the factory is initialized by discovering the mode if needed. * Uses double-checked locking for thread safety. @@ -277,10 +300,12 @@ private void discoverMode() { storage = selectedProvider.createStorage(); claimStrategy = selectedProvider.createClaimStrategy(); eventClaimStrategy = selectedProvider.createEventClaimStrategy(); + queueCancellationStrategy = selectedProvider.createQueueCancellationStrategy(); logger.info("Created BuildMemoryStorage: {}", storage.getClass().getSimpleName()); logger.info("Created NotificationClaimStrategy: {}", claimStrategy.getClass().getSimpleName()); logger.info("Created EventClaimStrategy: {}", eventClaimStrategy.getClass().getSimpleName()); + logger.info("Created QueueCancellationStrategy: {}", queueCancellationStrategy.getClass().getSimpleName()); } catch (Exception e) { logger.warn("Failed to discover mode via ExtensionList, using fallback", e); @@ -297,6 +322,7 @@ private void createFallbackMode() { storage = new LocalBuildMemoryStorage(); claimStrategy = new LocalNotificationClaimStrategy(); eventClaimStrategy = new LocalEventClaimStrategy(); + queueCancellationStrategy = new LocalQueueCancellationStrategy(); selectedMode = null; // No provider in fallback mode } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java index 2bcaabcfc..00e34c79c 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java @@ -25,10 +25,12 @@ import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.LocalEventClaimStrategy; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.LocalNotificationClaimStrategy; +import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.LocalQueueCancellationStrategy; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.BuildMemoryStorage; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.EventClaimStrategy; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.NotificationClaimStrategy; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.QueueCancellationStrategy; import com.sonyericsson.hudson.plugins.gerrit.trigger.storage.LocalBuildMemoryStorage; import hudson.Extension; @@ -113,6 +115,17 @@ public EventClaimStrategy createEventClaimStrategy() { return new LocalEventClaimStrategy(); } + /** + * Creates a new local queue cancellation strategy instance. + * Always returns false - no HA load balancer present in standalone mode. + * + * @return a new LocalQueueCancellationStrategy + */ + @Override + public QueueCancellationStrategy createQueueCancellationStrategy() { + return new LocalQueueCancellationStrategy(); + } + /** * Initializes local coordination mode. *

        diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index d2bbb6c53..6527f38b2 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -30,23 +30,30 @@ import com.google.gson.GsonBuilder; import com.hazelcast.core.HazelcastInstance; import com.hazelcast.map.IMap; +import com.hazelcast.map.listener.EntryAddedListener; import com.sonyericsson.hudson.plugins.gerrit.trigger.diagnostics.BuildMemoryReport; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory.MemoryImprint; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildsStartedStats; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.BuildMemoryStorage; import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; +import hudson.model.Executor; import hudson.model.Job; +import hudson.model.Result; import hudson.model.Run; +import hudson.security.ACL; +import hudson.security.ACLContext; import jenkins.model.Jenkins; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import edu.umd.cs.findbugs.annotations.CheckForNull; import edu.umd.cs.findbugs.annotations.NonNull; +import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; /** * Hazelcast-backed implementation of BuildMemoryStorage for HA/HS deployments. @@ -96,6 +103,26 @@ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { */ private static final String MAP_NAME = "gerrit-trigger-build-memory"; + /** + * Hazelcast map name for cross-replica build abort requests. + *

        + * When a replica needs to abort a build running on another replica, it puts an entry + * {@code "jobFullName:buildNumber"} into this map. Each Jenkins replica has a listener + * on this map and will abort any matching build running on its local executors. + *

        + * This approach works in Hazelcast CLIENT mode where {@code executeOnMember()} would + * target Hazelcast server sidecars (not Jenkins replicas) and fail with + * {@code ClassNotFoundException} because Jenkins classes are absent from the sidecar JVM. + */ + private static final String ABORT_INBOX_MAP_NAME = "gerrit-trigger-abort-inbox"; + + /** + * TTL for abort inbox entries in seconds (1 minute). + * Entries expire automatically to prevent memory leaks in case the target build + * has already finished or is not found on any replica. + */ + private static final long ABORT_INBOX_TTL_SECONDS = 60; + /** * Gson instance for JSON serialization of events. * Configured to handle polymorphic event types by including runtime type information. @@ -113,8 +140,10 @@ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { * Distributed mode storage (coordination mode). * Lazy-initialized when first accessed. * Marked volatile for thread-safe double-checked locking pattern. + * Uses String keys (event IDs) instead of BuildMemoryKey to avoid Hazelcast classloader + * issues when deserializing plugin-specific key classes via Java serialization. */ - private transient volatile IMap distributedMemory = null; + private transient volatile IMap distributedMemory = null; /** * Constructor. @@ -123,6 +152,65 @@ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { */ public HazelcastBuildMemoryStorage(@NonNull HazelcastInstance hazelcastInstance) { this.hazelcastInstance = hazelcastInstance; + registerAbortInboxListener(); + } + + /** + * Registers a listener on the abort inbox IMap so this replica can receive and process + * cross-replica build abort requests submitted by other Jenkins replicas. + *

        + * The listener fires in this JVM (which has Jenkins on its classpath), so it can safely + * look up jobs and interrupt executors. This is the correct approach for Hazelcast CLIENT + * mode where {@code executeOnMember()} would instead run code on the Hazelcast sidecar. + */ + private void registerAbortInboxListener() { + if (hazelcastInstance == null) { + return; + } + IMap abortInbox = hazelcastInstance.getMap(ABORT_INBOX_MAP_NAME); + abortInbox.addEntryListener((EntryAddedListener)event -> { + String abortKey = event.getKey(); + int lastColon = abortKey.lastIndexOf(':'); + if (lastColon < 0) { + logger.warn("Abort-inbox: invalid key (no colon separator): {}", abortKey); + return; + } + String jobName = abortKey.substring(0, lastColon); + String buildId = abortKey.substring(lastColon + 1); + handleAbortRequest(jobName, buildId); + }, false); + logger.debug("Registered abort-inbox listener on map: {}", ABORT_INBOX_MAP_NAME); + } + + /** + * Handles an abort request received via the abort inbox IMap. + * Looks up the build on this replica and interrupts it if still running. + * + * @param jobName full name of the job + * @param buildId build number as string + */ + private static void handleAbortRequest(String jobName, String buildId) { + try (ACLContext ignored = ACL.as(ACL.SYSTEM)) { + Jenkins jenkins = Jenkins.getInstanceOrNull(); + if (jenkins == null) { + return; + } + Job job = jenkins.getItemByFullName(jobName, Job.class); + if (job == null) { + return; + } + Run build = job.getBuildByNumber(Integer.parseInt(buildId)); + if (build == null || !build.isBuilding()) { + return; + } + Executor executor = build.getExecutor(); + if (executor != null) { + executor.interrupt(Result.ABORTED); + logger.info("Abort-inbox: interrupted job={} build={}", jobName, buildId); + } + } catch (Exception e) { + logger.error("Abort-inbox: failed to abort job={} build={}", jobName, buildId, e); + } } /** @@ -133,7 +221,7 @@ public HazelcastBuildMemoryStorage(@NonNull HazelcastInstance hazelcastInstance) * * @return distributed memory map, or null if Hazelcast unavailable */ - private IMap getDistributedMemory() { + private IMap getDistributedMemory() { // First check (no locking) - fast path for already-initialized case if (distributedMemory == null) { synchronized (this) { @@ -221,19 +309,9 @@ private MemoryImprint reconstructMemoryImprint(GerritTriggeredEvent event, Memor String projectFullName = entryData.getProjectFullName(); Job project = jenkins.getItemByFullName(projectFullName, Job.class); - logger.info("[HZ-DIAG] reconstruct: project='{}' found={} buildId='{}' completed={} cancelled={}", - projectFullName, project != null, entryData.getBuildId(), - entryData.isBuildCompleted(), entryData.isCancelled()); - if (project != null) { if (entryData.getBuildId() != null) { Run build = project.getBuild(entryData.getBuildId()); - String buildResult = "N/A"; - if (build != null) { - buildResult = String.valueOf(build.getResult()); - } - logger.info("[HZ-DIAG] reconstruct: project.getBuild('{}') = {} result={}", - entryData.getBuildId(), build != null, buildResult); if (build != null) { imprint.set(project, build, entryData.isBuildCompleted()); } else { @@ -266,19 +344,13 @@ private MemoryImprint reconstructMemoryImprint(GerritTriggeredEvent event, Memor @Override @CheckForNull public synchronized MemoryImprint getMemoryImprint(@NonNull GerritTriggeredEvent event) { - IMap map = getDistributedMemory(); + IMap map = getDistributedMemory(); if (map == null) { return null; } - BuildMemoryKey key = new BuildMemoryKey(event); + String key = EventIdentifier.generateEventId(event); MemoryImprintData data = map.get(key); - int entryCount = -1; - if (data != null && data.getEntries() != null) { - entryCount = data.getEntries().size(); - } - logger.info("[HZ-DIAG] getMemoryImprint key={} dataFound={} entries={}", - key, data != null, entryCount); if (data != null) { return reconstructMemoryImprint(event, data); } @@ -287,18 +359,16 @@ public synchronized MemoryImprint getMemoryImprint(@NonNull GerritTriggeredEvent @Override public synchronized void triggered(@NonNull GerritTriggeredEvent event, @NonNull Job project) { - IMap map = getDistributedMemory(); + IMap map = getDistributedMemory(); if (map == null) { logger.warn("Cannot record triggered - Hazelcast unavailable"); return; } - BuildMemoryKey key = new BuildMemoryKey(event); + String key = EventIdentifier.generateEventId(event); String projectFullName = project.getFullName(); String eventJson = serializeEvent(event); - logger.info("[HZ-DIAG] triggered key={} project={}", key, projectFullName); - // ATOMIC OPERATION - Distributed lock ensures only one replica modifies this entry at a time. // This is critical when multiple projects are triggered by the same event simultaneously. // Without atomic operations, concurrent threads can overwrite each other's entries, @@ -327,12 +397,6 @@ public synchronized void triggered(@NonNull GerritTriggeredEvent event, @NonNull data.addEntry(newEntry); } map.put(key, data); - int triggeredEntries = 0; - if (data.getEntries() != null) { - triggeredEntries = data.getEntries().size(); - } - logger.info("[HZ-DIAG] triggered stored: key={} found={} totalEntries={}", - key, found, triggeredEntries); if (!found) { logger.trace("Triggered event stored in distributed memory: {} for project: {}", key, projectFullName); } else { @@ -348,13 +412,13 @@ public synchronized void triggered(@NonNull GerritTriggeredEvent event, @NonNull @Override public synchronized void started(@NonNull GerritTriggeredEvent event, @NonNull Run build) { - IMap map = getDistributedMemory(); + IMap map = getDistributedMemory(); if (map == null) { logger.warn("Cannot mark started - Hazelcast unavailable"); return; } - BuildMemoryKey key = new BuildMemoryKey(event); + String key = EventIdentifier.generateEventId(event); String projectFullName = build.getParent().getFullName(); String buildId = build.getId(); @@ -399,19 +463,16 @@ public synchronized void started(@NonNull GerritTriggeredEvent event, @NonNull R @Override public synchronized void completed(@NonNull GerritTriggeredEvent event, @NonNull Run build) { - IMap map = getDistributedMemory(); + IMap map = getDistributedMemory(); if (map == null) { logger.warn("Cannot mark completed - Hazelcast unavailable"); return; } - BuildMemoryKey key = new BuildMemoryKey(event); + String key = EventIdentifier.generateEventId(event); String projectFullName = build.getParent().getFullName(); String buildId = build.getId(); - logger.info("[HZ-DIAG] completed key={} project={} buildId={} result={}", - key, projectFullName, buildId, build.getResult()); - // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). long completedTimestamp = System.currentTimeMillis(); map.lock(key); @@ -443,12 +504,6 @@ public synchronized void completed(@NonNull GerritTriggeredEvent event, @NonNull data.addEntry(newEntry); } map.put(key, data); - int completedEntries = 0; - if (data.getEntries() != null) { - completedEntries = data.getEntries().size(); - } - logger.info("[HZ-DIAG] completed stored: key={} found={} totalEntries={} buildCompleted={}", - key, found, completedEntries, true); if (!found) { logger.debug("Build completed without being registered first (distributed mode)."); } @@ -464,13 +519,13 @@ public synchronized void completed(@NonNull GerritTriggeredEvent event, @NonNull @Override public synchronized void retriggered(@NonNull GerritTriggeredEvent event, @NonNull Job project, @CheckForNull List otherBuilds) { - IMap map = getDistributedMemory(); + IMap map = getDistributedMemory(); if (map == null) { logger.warn("Cannot record retriggered - Hazelcast unavailable"); return; } - BuildMemoryKey key = new BuildMemoryKey(event); + String key = EventIdentifier.generateEventId(event); String projectFullName = project.getFullName(); String eventJson = serializeEvent(event); @@ -521,13 +576,13 @@ public synchronized void retriggered(@NonNull GerritTriggeredEvent event, @NonNu @Override public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull Job project) { - IMap map = getDistributedMemory(); + IMap map = getDistributedMemory(); if (map == null) { logger.warn("Cannot mark cancelled - Hazelcast unavailable"); return; } - BuildMemoryKey key = new BuildMemoryKey(event); + String key = EventIdentifier.generateEventId(event); String projectFullName = project.getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). @@ -544,13 +599,13 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull for (EntryData entryData : data.getEntries()) { if (projectFullName.equals(entryData.getProjectFullName())) { found = true; - // Only mark as cancelled if the build never started (buildId is null) - // OR was actively being cancelled by a new patchset (cancelling flag was set). - // If buildId is already set but cancelling is false, this is a cross-replica - // queue deduplication: the actual build is running on another replica. - // Writing cancelled=true in that case poisons the shared map and causes - // premature "No Builds Executed" feedback. - if (entryData.getBuildId() == null || entryData.isCancelling()) { + // Only mark as completed when our own code explicitly flagged this entry + // for cancellation (setCancelling was called by cancelOutdatedEvents). + // External queue cancellations (e.g. QueueLoadBalancer moving the item to + // another replica) must be skipped: setting completed=true here would cause + // allBuildsCompleted() to call forget(), removing the event from the shared + // IMap before the other replica has a chance to run cancelOutdatedEvents(). + if (entryData.isCancelling()) { entryData.setCancelled(true); entryData.setCancelling(false); entryData.setCompletedTimestamp(cancelledTimestamp); @@ -558,29 +613,24 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull modified = true; } else { logger.debug("Skipping cancelled() for project={} event={}: " - + "buildId already set by another replica (cross-replica queue dedup)", - projectFullName, key); + + "isCancelling=false, buildId={}. Likely external cancellation " + + "(e.g. QueueLoadBalancer); not marking as completed.", + projectFullName, key, entryData.getBuildId()); } break; } } } if (!found) { - EntryData newEntry = new EntryData(); - newEntry.setProjectFullName(projectFullName); - newEntry.setCancelled(true); - newEntry.setCancelling(false); - newEntry.setCompletedTimestamp(cancelledTimestamp); - newEntry.setBuildCompleted(true); - data.addEntry(newEntry); - modified = true; + // No entry for this project - skip. If the entry was explicitly cancelled + // (isCancelling was set), it would have been found because setCancelling() + // only updates existing entries. This path is an untracked external cancellation. + logger.debug("cancelled() called for untracked project={} event={}: skipping.", + projectFullName, key); } if (modified) { map.put(key, data); } - if (!found) { - logger.debug("Build cancelled without being registered first (distributed mode)."); - } logger.trace("Cancelled event stored in distributed memory: {}", key); } catch (Exception e) { logger.error("Failed to mark cancelled in distributed memory: project={}, event={}", @@ -592,16 +642,17 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull @Override public synchronized void setCancelling(@NonNull GerritTriggeredEvent event, @NonNull Job project) { - IMap map = getDistributedMemory(); + IMap map = getDistributedMemory(); if (map == null) { logger.warn("Cannot mark cancelling - Hazelcast unavailable"); return; } - BuildMemoryKey key = new BuildMemoryKey(event); + String key = EventIdentifier.generateEventId(event); String projectFullName = project.getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). + List buildIdsToAbort = new ArrayList<>(); map.lock(key); try { MemoryImprintData data = map.get(key); @@ -612,6 +663,10 @@ public synchronized void setCancelling(@NonNull GerritTriggeredEvent event, @Non if (!entryData.isBuildCompleted() && !entryData.isCancelling() && !entryData.isCancelled()) { entryData.setCancelling(true); updated = true; + String buildId = entryData.getBuildId(); + if (buildId != null) { + buildIdsToAbort.add(buildId); + } } } } @@ -626,16 +681,30 @@ public synchronized void setCancelling(@NonNull GerritTriggeredEvent event, @Non } finally { map.unlock(key); } + + // Put abort requests into the distributed abort inbox for cross-replica cancellation. + // Each Jenkins replica has a listener on this map (registered in the constructor) and will + // abort any matching build running on its local executors. + // NOTE: executeOnMember() cannot be used in Hazelcast CLIENT mode because it runs the task + // on the Hazelcast sidecar JVM, which does not have Jenkins classes on its classpath. + if (!buildIdsToAbort.isEmpty()) { + IMap abortInbox = hazelcastInstance.getMap(ABORT_INBOX_MAP_NAME); + for (String buildId : buildIdsToAbort) { + String abortKey = projectFullName + ":" + buildId; + abortInbox.put(abortKey, System.currentTimeMillis(), ABORT_INBOX_TTL_SECONDS, TimeUnit.SECONDS); + logger.info("Queued cross-replica abort: job={} build={}", projectFullName, buildId); + } + } } @Override public synchronized void forget(@NonNull GerritTriggeredEvent event) { - IMap map = getDistributedMemory(); + IMap map = getDistributedMemory(); if (map == null) { return; } - BuildMemoryKey key = new BuildMemoryKey(event); + String key = EventIdentifier.generateEventId(event); map.remove(key); logger.trace("Forgot event from distributed memory: {}", key); } @@ -644,16 +713,16 @@ public synchronized void forget(@NonNull GerritTriggeredEvent event) { public synchronized void removeProject(@NonNull Job project) { String projectFullName = project.getFullName(); - IMap map = getDistributedMemory(); + IMap map = getDistributedMemory(); if (map == null) { return; } // ATOMIC OPERATION - Distributed lock per key. EntryProcessor not used (ClassNotFoundException in client mode). // Collect keys first to avoid ConcurrentModificationException - java.util.Set keys = new java.util.HashSet<>(map.keySet()); + java.util.Set keys = new java.util.HashSet<>(map.keySet()); - for (BuildMemoryKey key : keys) { + for (String key : keys) { map.lock(key); try { MemoryImprintData data = map.get(key); @@ -686,16 +755,7 @@ public synchronized void removeProject(@NonNull Job project) { @Override public synchronized boolean isAllBuildsCompleted(@NonNull GerritTriggeredEvent event) { MemoryImprint imprint = getMemoryImprint(event); - boolean result = imprint != null && imprint.isAllBuildsCompleted(); - logger.info("[HZ-DIAG] isAllBuildsCompleted: imprintNull={} result={}", - imprint == null, result); - if (imprint != null) { - logger.info("[HZ-DIAG] isAllBuildsCompleted: wereAllBuildsSuccessful={} wereAnyBuildsFailed={}" - + " wereAllBuildsNotBuilt={}", - imprint.wereAllBuildsSuccessful(), imprint.wereAnyBuildsFailed(), - imprint.wereAllBuildsNotBuilt()); - } - return result; + return imprint != null && imprint.isAllBuildsCompleted(); } @Override @@ -783,13 +843,13 @@ public synchronized List getBuilds(@NonNull GerritTriggeredEvent event) { @Override public void setEntryCustomUrl(@NonNull GerritTriggeredEvent event, @NonNull Run r, @CheckForNull String customUrl) { - IMap map = getDistributedMemory(); + IMap map = getDistributedMemory(); if (map == null) { logger.warn("Cannot set custom URL - Hazelcast unavailable"); return; } - BuildMemoryKey key = new BuildMemoryKey(event); + String key = EventIdentifier.generateEventId(event); String projectFullName = r.getParent().getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). @@ -825,13 +885,13 @@ public void setEntryCustomUrl(@NonNull GerritTriggeredEvent event, @NonNull Run @Override public void setEntryUnsuccessfulMessage(@NonNull GerritTriggeredEvent event, @NonNull Run r, @CheckForNull String unsuccessfulMessage) { - IMap map = getDistributedMemory(); + IMap map = getDistributedMemory(); if (map == null) { logger.warn("Cannot set unsuccessful message - Hazelcast unavailable"); return; } - BuildMemoryKey key = new BuildMemoryKey(event); + String key = EventIdentifier.generateEventId(event); String projectFullName = r.getParent().getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). @@ -869,13 +929,13 @@ public void setEntryUnsuccessfulMessage(@NonNull GerritTriggeredEvent event, @No public synchronized BuildMemoryReport report() { BuildMemoryReport report = new BuildMemoryReport(); - IMap map = getDistributedMemory(); + IMap map = getDistributedMemory(); if (map == null) { return report; } // Read all entries from distributed memory - for (Map.Entry mapEntry : map.entrySet()) { + for (Map.Entry mapEntry : map.entrySet()) { MemoryImprintData data = mapEntry.getValue(); GerritTriggeredEvent event = deserializeEvent(data.getEventJson()); @@ -896,13 +956,13 @@ public synchronized BuildMemoryReport report() { public synchronized Map getAllEvents() { Map result = new HashMap<>(); - IMap map = getDistributedMemory(); + IMap map = getDistributedMemory(); if (map == null) { return result; } // Convert all entries - for (Map.Entry entry : map.entrySet()) { + for (Map.Entry entry : map.entrySet()) { MemoryImprintData data = entry.getValue(); if (data != null) { GerritTriggeredEvent event = deserializeEvent(data.getEventJson()); diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java index 466d4fd1f..544c9c7de 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java @@ -26,6 +26,7 @@ import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.EventClaimStrategy; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.NotificationClaimStrategy; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.QueueCancellationStrategy; import hudson.Extension; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -187,6 +188,20 @@ public EventClaimStrategy createEventClaimStrategy() { return new HazelcastEventClaimStrategy(instance); } + /** + * Creates Hazelcast queue cancellation strategy. + *

        + * Detects cancellations triggered by the CloudBees HA load balancer so that + * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.GerritQueueListener} + * can skip them and avoid sending premature Gerrit feedback. + * + * @return HazelcastQueueCancellationStrategy instance + */ + @Override + public QueueCancellationStrategy createQueueCancellationStrategy() { + return new HazelcastQueueCancellationStrategy(); + } + /** * Initializes Hazelcast coordination mode. *

        diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastQueueCancellationStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastQueueCancellationStrategy.java new file mode 100644 index 000000000..e990a532c --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastQueueCancellationStrategy.java @@ -0,0 +1,76 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.QueueCancellationStrategy; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.model.Queue.LeftItem; + +import java.util.logging.Logger; + +/** + * Hazelcast (distributed) implementation of QueueCancellationStrategy. + * + *

        Detects queue item cancellations triggered by the CloudBees HA load balancer + * (QueueLoadBalancer), which moves queue items between replicas by cancelling the + * original and re-queuing it on the target replica. These cancellations must be + * ignored to avoid sending premature "build cancelled" feedback to Gerrit.

        + * + *

        Class names are checked via string matching to avoid a mandatory compile-time + * dependency on the CloudBees replication plugin.

        + * + * @see com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastCoordinationProvider + * @see QueueCancellationStrategy + */ +public class HazelcastQueueCancellationStrategy extends QueueCancellationStrategy { + + private static final Logger logger = Logger.getLogger(HazelcastQueueCancellationStrategy.class.getName()); + + /** + * Returns true if the cancelled item was moved by the HA load balancer. + * + *

        Two markers are checked (either is sufficient):

        + *
          + *
        • {@code QueueLoadBalancerAction} in the item's actions — present on the + * new queue item created on the target replica.
        • + *
        • {@code LoadBalancedCauseOfBlockage} as cause-of-blockage — present on + * the original item cancelled by {@code CancelQueueItem}.
        • + *
        + * + * @param item the queue item that left the queue as cancelled + * @return true if cancelled by the HA load balancer + */ + @Override + public boolean isLoadBalancedCancellation(@NonNull LeftItem item) { + boolean result = item.getActions().stream() + .anyMatch(a -> a.getClass().getName().contains("QueueLoadBalancerAction")) + || (item.getCauseOfBlockage() != null + && item.getCauseOfBlockage().getClass().getName() + .contains("LoadBalancedCauseOfBlockage")); + if (result) { + logger.fine("Queue item cancelled due to HA load balancing, skipping Gerrit cancellation: " + item); + } + return result; + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalQueueCancellationStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalQueueCancellationStrategy.java new file mode 100644 index 000000000..d138e1da3 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalQueueCancellationStrategy.java @@ -0,0 +1,49 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier; + +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.QueueCancellationStrategy; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.model.Queue.LeftItem; + +/** + * Local (standalone) implementation of QueueCancellationStrategy. + * Always returns false since there is no HA load balancer in single-instance mode. + * + * @see com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.LocalCoordinationProvider + * @see QueueCancellationStrategy + */ +public class LocalQueueCancellationStrategy extends QueueCancellationStrategy { + + /** + * Always returns false in standalone mode — no load balancer is present. + * + * @param item the queue item that was cancelled + * @return false + */ + @Override + public boolean isLoadBalancedCancellation(@NonNull LeftItem item) { + return false; + } +} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index 9bb7d6fd1..5bb2755eb 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -455,7 +455,9 @@ private boolean shouldIgnoreEvent( if (!abortBecauseOfTopic) { Change change = runningChangeBasedEvent.getChange(); - if (!change.equals(event.getChange())) { + Change newChange = event.getChange(); + boolean changesEqual = change != null && change.equals(newChange); + if (!changesEqual) { return true; } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/EventListener.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/EventListener.java index 5926f28bf..f8a001e34 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/EventListener.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/EventListener.java @@ -25,6 +25,7 @@ import com.sonyericsson.hudson.plugins.gerrit.trigger.config.IGerritHudsonTriggerConfig; import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.CoordinationModeFactory; +import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.BuildCancellationPolicy; import com.sonyericsson.hudson.plugins.gerrit.trigger.events.ManualPatchsetCreated; import com.sonyericsson.hudson.plugins.gerrit.trigger.events.lifecycle.GerritEventLifecycle; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.ToGerritRunListener; @@ -259,12 +260,12 @@ private void abortBuild(GerritTrigger t, GerritTriggeredEvent event) { return; } + BuildCancellationPolicy policy = t.getBuildCancellationPolicy(); // Per-trigger cancellation policy - if (t.getBuildCancellationPolicy() != null && t.getBuildCancellationPolicy().isEnabled()) { - logger.debug("Cancelling builds for event {} using trigger policy", changeBasedEvent); + if (policy != null && policy.isEnabled()) { listener.getMemory().cancelTriggeredJob( changeBasedEvent, - t.getBuildCancellationPolicy(), + policy, t, t.getJob()); } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/GerritQueueListener.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/GerritQueueListener.java index d58d5f239..50abc958c 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/GerritQueueListener.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/GerritQueueListener.java @@ -11,6 +11,7 @@ import java.util.logging.Level; import java.util.logging.Logger; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.CoordinationModeFactory; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.ToGerritRunListener; import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; @@ -25,6 +26,9 @@ public class GerritQueueListener extends QueueListener { @Override public void onLeft(LeftItem item) { if (item.isCancelled() && item.task instanceof Job) { + if (CoordinationModeFactory.get().getQueueCancellationStrategy().isLoadBalancedCancellation(item)) { + return; + } for (Cause cause : item.getCauses()) { if (cause instanceof GerritCause && !((GerritCause)cause).isSilentMode()) { GerritCause gerritCause = (GerritCause)cause; diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java index 0d8d4cefd..21d320270 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java @@ -163,6 +163,26 @@ public static String getConfiguredMode() { */ public abstract EventClaimStrategy createEventClaimStrategy(); + /** + * Creates a new QueueCancellationStrategy instance for this mode. + * + *

        Called once during factory initialization after this provider is selected + * as the highest-priority available provider.

        + * + *

        The QueueCancellationStrategy determines whether a cancelled Jenkins queue item + * should be ignored because it was moved by the HA load balancer rather than being + * cancelled by a user or a new patchset event. In local mode, this is a NO-OP (always + * returns false). In distributed mode (e.g., Hazelcast), this inspects the item for + * load-balancer markers.

        + * + *

        Thread Safety: This method may be called from multiple threads during + * factory initialization (double-checked locking). Implementations should be stateless + * or properly synchronized.

        + * + * @return a new QueueCancellationStrategy instance (non-null) + */ + public abstract QueueCancellationStrategy createQueueCancellationStrategy(); + /** * Initializes this coordination mode provider. * diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/QueueCancellationStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/QueueCancellationStrategy.java new file mode 100644 index 000000000..10dc04e4b --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/QueueCancellationStrategy.java @@ -0,0 +1,54 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.spi; + +import hudson.model.Queue.LeftItem; +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * Abstract base class for queue cancellation strategies in different deployment modes. + * + *

        Determines whether a cancelled Jenkins queue item should be ignored because it was + * cancelled by the HA load balancer (i.e. migrated to another replica), not by a user + * or by a new patchset event.

        + * + *
          + *
        • Local mode: Always returns false — no load balancer present in standalone mode.
        • + *
        • Hazelcast mode: Inspects the item's actions and cause-of-blockage for + * load-balancer markers to avoid sending premature Gerrit feedback.
        • + *
        + * + * @see CoordinationModeProvider + */ +public abstract class QueueCancellationStrategy { + + /** + * Determines whether a cancelled queue item should be ignored because it was + * cancelled by the HA load balancer moving it to another replica. + * + * @param item the queue item that left the queue as cancelled + * @return true if the cancellation is an HA load-balancing operation and should be skipped + */ + public abstract boolean isLoadBalancedCancellation(@NonNull LeftItem item); +} From 751bea2ae3dcad76e3887cf91302ddb3dccf113f Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 2 Jun 2026 10:17:47 +0200 Subject: [PATCH 33/87] Peer review 12 (PluginImpl) - direct provider chosen --- .../plugins/gerrit/trigger/PluginImpl.java | 57 ++++++++++--------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java index 005b0a153..e0cf3628a 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java @@ -24,8 +24,10 @@ */ package com.sonyericsson.hudson.plugins.gerrit.trigger; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.LocalCoordinationProvider; import com.sonyericsson.hudson.plugins.gerrit.trigger.dependency.DependencyQueueTaskDispatcher; import com.sonyericsson.hudson.plugins.gerrit.trigger.replication.ReplicationQueueTaskDispatcher; +import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider; import com.sonymobile.tools.gerrit.gerritevents.GerritHandler; import com.sonymobile.tools.gerrit.gerritevents.GerritSendCommandQueue; import com.sonyericsson.hudson.plugins.gerrit.trigger.config.Config; @@ -692,43 +694,44 @@ private void waitForHazelcastCluster() { * and the factory will fall back to the next highest-priority provider. */ private void initializeCoordinationProviders() { - logger.debug("Discovering active coordination provider..."); - hudson.ExtensionList providers = - hudson.ExtensionList.lookup( - com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider.class); - - // Get configured mode to determine which provider to initialize - String configuredMode = com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider - .getConfiguredMode(); + ExtensionList providers = ExtensionList.lookup(CoordinationModeProvider.class); + String configuredMode = CoordinationModeProvider.getConfiguredMode(); logger.debug("Configured coordination mode: {}", configuredMode); - // ExtensionList is already ordered by ordinal (highest first) - // Find and initialize the provider that matches the configured mode - for (com.sonyericsson.hudson.plugins.gerrit.trigger.spi.CoordinationModeProvider provider : providers) { - // Check if this provider's mode matches the configuration - // We cannot use isAvailable() here because it checks initialization status - String providerMode = provider.getModeName(); - boolean matches = providerMode.toLowerCase().contains(configuredMode.toLowerCase()) - || configuredMode.equalsIgnoreCase("default") && providerMode.equals("Local"); - - logger.debug("Checking provider: {} (matches={})", providerMode, matches); + // Try to initialize the configured (non-local) provider first. + // Local is handled separately below as the explicit fallback. + if (!"local".equalsIgnoreCase(configuredMode)) { + for (CoordinationModeProvider provider : providers) { + if (provider.getModeName().equalsIgnoreCase(configuredMode)) { + try { + logger.info("Initializing coordination provider: {}", provider.getModeName()); + provider.initialize(); + logger.info("Provider {} initialized successfully", provider.getModeName()); + return; + } catch (Exception e) { + logger.warn("Failed to initialize {} coordination provider. Falling back to Local.", + provider.getModeName(), e); + break; + } + } + } + } - if (matches) { + // Explicit fallback to LocalCoordinationProvider, which is always available. + for (CoordinationModeProvider provider : providers) { + if (provider instanceof LocalCoordinationProvider) { + logger.info("Initializing LocalCoordinationProvider{}", + "local".equalsIgnoreCase(configuredMode) ? "" : " (fallback after failed initialization)"); try { - logger.info("Initializing coordination provider: {}", provider.getModeName()); provider.initialize(); - logger.info("Provider {} initialized successfully", provider.getModeName()); - return; // Only initialize the matching provider } catch (Exception e) { - logger.warn("Failed to initialize coordination provider: {}. " - + "Will fall back to next available provider.", provider.getModeName(), e); - // Continue to next provider if this one fails + logger.error("Failed to initialize LocalCoordinationProvider - this should never happen", e); } + return; } } - logger.warn("No coordination provider initialized - this should not happen as LocalCoordinationProvider " - + "should always be available"); + logger.error("LocalCoordinationProvider not found - this should never happen"); } /** From ccc1e43df55c07704b49dce99cab6f6d1e79bcb2 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 2 Jun 2026 10:33:00 +0200 Subject: [PATCH 34/87] Peer review 1 (LocalBuildMemoryStorage) - Equal should be use instead of == --- .../hudson/plugins/gerrit/trigger/PluginImpl.java | 7 +++++-- .../trigger/storage/LocalBuildMemoryStorage.java | 10 +++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java index e0cf3628a..8e3a817bb 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java @@ -720,8 +720,11 @@ private void initializeCoordinationProviders() { // Explicit fallback to LocalCoordinationProvider, which is always available. for (CoordinationModeProvider provider : providers) { if (provider instanceof LocalCoordinationProvider) { - logger.info("Initializing LocalCoordinationProvider{}", - "local".equalsIgnoreCase(configuredMode) ? "" : " (fallback after failed initialization)"); + if ("local".equalsIgnoreCase(configuredMode)) { + logger.info("Initializing LocalCoordinationProvider"); + } else { + logger.info("Initializing LocalCoordinationProvider (fallback after failed initialization)"); + } try { provider.initialize(); } catch (Exception e) { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java index 2f3adc750..68f6426c7 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java @@ -332,10 +332,10 @@ public synchronized Map getAllEvents() { @Override public boolean eventsMatch(@NonNull GerritTriggeredEvent event1, @NonNull GerritTriggeredEvent event2) { - // In local mode, use identity comparison as an optimization since the same event object - // instance is passed through the system. Events do implement logical equals() (see - // GerritCause and BadgeAction) which is used for TreeMap key lookup. The identity check - // here is purely for performance in cancellation logic. - return event1 == event2; + // Use logical equality so that deserialized event instances (e.g. GerritCause.tEvent + // loaded from disk) are correctly matched against in-memory events. + // Most trigger events extend ChangeBasedEvent, whose equals() compares eventType, + // change, and patchSet fields — all stable across serialization boundaries. + return event1.equals(event2); } } From ccef94e3562bb761a5f2d82a9a96479d21fbc244 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 2 Jun 2026 10:42:53 +0200 Subject: [PATCH 35/87] Peer review 1 (LocalBuildMemoryStorage) - Equal should be use instead of == --- .../plugins/gerrit/trigger/spi/BuildMemoryStorage.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java index 0958cfe5f..9e511a25d 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java @@ -289,8 +289,11 @@ public abstract void setEntryUnsuccessfulMessage(@NonNull GerritTriggeredEvent e * This method allows each storage implementation to define its own event equality * semantics. This is critical for proper operation in different coordination modes: *
          - *
        • Local mode: Uses identity comparison (==) as an optimization since - * the same event object instance is passed through the system. Note that event classes + *
        • Local mode: Use logical equality so that deserialized + * event instances (e.g. GerritCause.tEvent loaded from disk) are correctly + * matched against in-memory events. Most trigger events extend ChangeBasedEvent, + * whose equals() compares eventType, change, and patchSet fields — all stable + * across serialization boundaries. Note that event classes * implement logical {@code .equals()} (see * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.GerritCause} and * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.BadgeAction}), From 6b4a37acb5f0d756287aac3d0c2952c7474b8769 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 2 Jun 2026 12:23:08 +0200 Subject: [PATCH 36/87] Peer review 2 (HazelcastBuildMemoryStorage) - AbandonedPatchsetInterruption --- .../HazelcastBuildMemoryStorage.java | 93 +++++++++++++++---- .../gerritnotifier/model/BuildMemory.java | 5 + .../trigger/spi/BuildMemoryStorage.java | 25 +++++ 3 files changed, 107 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 6527f38b2..8460cbeac 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -34,6 +34,8 @@ import com.sonyericsson.hudson.plugins.gerrit.trigger.diagnostics.BuildMemoryReport; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory.MemoryImprint; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildsStartedStats; +import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.AbandonedPatchsetInterruption; +import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.NewPatchSetInterruption; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.BuildMemoryStorage; import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; import hudson.model.Executor; @@ -41,6 +43,7 @@ import hudson.model.Result; import hudson.model.Run; import hudson.security.ACL; +import jenkins.model.CauseOfInterruption; import hudson.security.ACLContext; import jenkins.model.Jenkins; import org.slf4j.Logger; @@ -123,6 +126,18 @@ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { */ private static final long ABORT_INBOX_TTL_SECONDS = 60; + /** + * Abort inbox value indicating the build was superseded by a new patchset. + * The receiving replica will use {@link NewPatchSetInterruption} as the cause. + */ + private static final String CAUSE_NEW_PATCHSET = "NEW_PATCHSET"; + + /** + * Abort inbox value indicating the patchset was abandoned. + * The receiving replica will use {@link AbandonedPatchsetInterruption} as the cause. + */ + private static final String CAUSE_ABANDONED = "ABANDONED"; + /** * Gson instance for JSON serialization of events. * Configured to handle polymorphic event types by including runtime type information. @@ -167,8 +182,8 @@ private void registerAbortInboxListener() { if (hazelcastInstance == null) { return; } - IMap abortInbox = hazelcastInstance.getMap(ABORT_INBOX_MAP_NAME); - abortInbox.addEntryListener((EntryAddedListener)event -> { + IMap abortInbox = hazelcastInstance.getMap(ABORT_INBOX_MAP_NAME); + abortInbox.addEntryListener((EntryAddedListener)event -> { String abortKey = event.getKey(); int lastColon = abortKey.lastIndexOf(':'); if (lastColon < 0) { @@ -177,7 +192,8 @@ private void registerAbortInboxListener() { } String jobName = abortKey.substring(0, lastColon); String buildId = abortKey.substring(lastColon + 1); - handleAbortRequest(jobName, buildId); + String causeType = event.getValue(); + handleAbortRequest(jobName, buildId, causeType); }, false); logger.debug("Registered abort-inbox listener on map: {}", ABORT_INBOX_MAP_NAME); } @@ -185,11 +201,19 @@ private void registerAbortInboxListener() { /** * Handles an abort request received via the abort inbox IMap. * Looks up the build on this replica and interrupts it if still running. + *

          + * The {@code causeType} string determines which {@link CauseOfInterruption} subclass + * is used so that the aborted build is annotated with the correct reason: + *

            + *
          • {@value #CAUSE_ABANDONED} → {@link AbandonedPatchsetInterruption}
          • + *
          • {@value #CAUSE_NEW_PATCHSET} (or any other value) → {@link NewPatchSetInterruption}
          • + *
          * - * @param jobName full name of the job - * @param buildId build number as string + * @param jobName full name of the job + * @param buildId build number as string + * @param causeType cause type string ({@value #CAUSE_ABANDONED} or {@value #CAUSE_NEW_PATCHSET}) */ - private static void handleAbortRequest(String jobName, String buildId) { + private static void handleAbortRequest(String jobName, String buildId, String causeType) { try (ACLContext ignored = ACL.as(ACL.SYSTEM)) { Jenkins jenkins = Jenkins.getInstanceOrNull(); if (jenkins == null) { @@ -205,8 +229,14 @@ private static void handleAbortRequest(String jobName, String buildId) { } Executor executor = build.getExecutor(); if (executor != null) { - executor.interrupt(Result.ABORTED); - logger.info("Abort-inbox: interrupted job={} build={}", jobName, buildId); + CauseOfInterruption cause; + if (CAUSE_ABANDONED.equals(causeType)) { + cause = new AbandonedPatchsetInterruption(); + } else { + cause = new NewPatchSetInterruption(); + } + executor.interrupt(Result.ABORTED, cause); + logger.info("Abort-inbox: interrupted job={} build={} cause={}", jobName, buildId, causeType); } } catch (Exception e) { logger.error("Abort-inbox: failed to abort job={} build={}", jobName, buildId, e); @@ -652,7 +682,6 @@ public synchronized void setCancelling(@NonNull GerritTriggeredEvent event, @Non String projectFullName = project.getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). - List buildIdsToAbort = new ArrayList<>(); map.lock(key); try { MemoryImprintData data = map.get(key); @@ -663,10 +692,6 @@ public synchronized void setCancelling(@NonNull GerritTriggeredEvent event, @Non if (!entryData.isBuildCompleted() && !entryData.isCancelling() && !entryData.isCancelled()) { entryData.setCancelling(true); updated = true; - String buildId = entryData.getBuildId(); - if (buildId != null) { - buildIdsToAbort.add(buildId); - } } } } @@ -681,6 +706,42 @@ public synchronized void setCancelling(@NonNull GerritTriggeredEvent event, @Non } finally { map.unlock(key); } + } + + @Override + public synchronized void requestCrossReplicaAbort(@NonNull GerritTriggeredEvent event, @NonNull Job project, + @NonNull CauseOfInterruption cause) { + if (hazelcastInstance == null) { + return; + } + + IMap map = getDistributedMemory(); + if (map == null) { + return; + } + + String key = EventIdentifier.generateEventId(event); + String projectFullName = project.getFullName(); + String causeType; + if (cause instanceof AbandonedPatchsetInterruption) { + causeType = CAUSE_ABANDONED; + } else { + causeType = CAUSE_NEW_PATCHSET; + } + + // Collect build IDs that were marked as cancelling for this project/event. + // setCancelling() has already set isCancelling=true in the distributed map. + List buildIdsToAbort = new ArrayList<>(); + MemoryImprintData data = map.get(key); + if (data != null && data.getEntries() != null) { + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName()) + && entryData.isCancelling() + && entryData.getBuildId() != null) { + buildIdsToAbort.add(entryData.getBuildId()); + } + } + } // Put abort requests into the distributed abort inbox for cross-replica cancellation. // Each Jenkins replica has a listener on this map (registered in the constructor) and will @@ -688,11 +749,11 @@ public synchronized void setCancelling(@NonNull GerritTriggeredEvent event, @Non // NOTE: executeOnMember() cannot be used in Hazelcast CLIENT mode because it runs the task // on the Hazelcast sidecar JVM, which does not have Jenkins classes on its classpath. if (!buildIdsToAbort.isEmpty()) { - IMap abortInbox = hazelcastInstance.getMap(ABORT_INBOX_MAP_NAME); + IMap abortInbox = hazelcastInstance.getMap(ABORT_INBOX_MAP_NAME); for (String buildId : buildIdsToAbort) { String abortKey = projectFullName + ":" + buildId; - abortInbox.put(abortKey, System.currentTimeMillis(), ABORT_INBOX_TTL_SECONDS, TimeUnit.SECONDS); - logger.info("Queued cross-replica abort: job={} build={}", projectFullName, buildId); + abortInbox.put(abortKey, causeType, ABORT_INBOX_TTL_SECONDS, TimeUnit.SECONDS); + logger.info("Queued cross-replica abort: job={} build={} cause={}", projectFullName, buildId, causeType); } } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index 5bb2755eb..7c4ea7d33 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -544,6 +544,11 @@ private void cancelMatchingJobs( e.interrupt(Result.ABORTED, cause); } } + + // Notify other replicas to abort matching builds on their local executors. + // In standalone mode this is a no-op; in distributed mode the storage + // puts a cause-typed entry into the abort inbox IMap. + storage.requestCrossReplicaAbort(event, job, cause); } catch (Exception e) { logger.error("Error canceling job", e); } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java index 9e511a25d..86a9fa8fd 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java @@ -29,6 +29,7 @@ import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; import hudson.model.Job; import hudson.model.Run; +import jenkins.model.CauseOfInterruption; import edu.umd.cs.findbugs.annotations.CheckForNull; import edu.umd.cs.findbugs.annotations.NonNull; @@ -283,6 +284,30 @@ public abstract void setEntryUnsuccessfulMessage(@NonNull GerritTriggeredEvent e @NonNull public abstract Map getAllEvents(); + /** + * Requests cross-replica abort for builds tracked for this event and project. + *

          + * In distributed deployments, this notifies other replicas to abort any + * matching builds running on their local executors. The cause of interruption + * is forwarded so the aborted build is annotated with the correct reason + * (e.g. {@link com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.AbandonedPatchsetInterruption} + * vs {@link com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.NewPatchSetInterruption}). + *

          + * This method is called from + * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory#cancelOutdatedBuilds} + * after {@link #setCancelling} has already marked the entry, so the cause is known at this point. + *

          + * The default implementation is a no-op — standalone Jenkins has no other replicas to notify. + * + * @param event the event whose builds should be aborted on remote replicas + * @param project the project being cancelled + * @param cause the cause of interruption + */ + public void requestCrossReplicaAbort(@NonNull GerritTriggeredEvent event, @NonNull Job project, + @NonNull CauseOfInterruption cause) { + // Default no-op: standalone mode has no other replicas to notify + } + /** * Checks if two events are logically equivalent for cancellation purposes. *

          From ae76e3bff2a565838f0db4001cc87a7ff1ae592e Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 2 Jun 2026 13:40:49 +0200 Subject: [PATCH 37/87] Fix self-cancellation when isAbortNewPatchsets=true with multiple triggered jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Gerrit event triggers multiple jobs and the server-wide BuildCurrentPatchesOnly policy has isAbortNewPatchsets=true, cancelOutdatedEvents() was incorrectly marking the triggering event itself as outdated (self-cancellation). The second job's cancelOutdatedEvents call would find the first job's entry already in memory, and since isAbortNewPatchsets=true causes shouldIgnoreEvent() to return false even for equal patchset numbers, the entry was added to outdatedEvents and setCancelling(true) was called on the second job's entry — before the build was even scheduled. When a new patchset (PS2) later arrived, the isCancelling=true flag caused hasActiveBuildsForJob to return false for that job, so the PS1 build was never aborted. Fix: skip cancellation when the running event logically matches the new event (using storage.eventsMatch()), preventing any event from cancelling itself. Co-Authored-By: Claude Sonnet 4.6 --- .../trigger/gerritnotifier/model/BuildMemory.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index 7c4ea7d33..ac10dd47a 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -369,6 +369,16 @@ public void cancelOutdatedEvents( ChangeBasedEvent runningChangeBasedEvent = (ChangeBasedEvent)runningEvent; logger.debug("Checking running event: {}", runningChangeBasedEvent); + // Never cancel an event against itself (self-cancellation). + // This can happen when isAbortNewPatchsets=true and the same event is + // processed by multiple jobs — the second job finds the first job's entry + // in memory and considers the event "outdated" against itself, poisoning + // the isCancelling flag before the build is even scheduled. + if (storage.eventsMatch(newEvent, runningChangeBasedEvent)) { + logger.debug("Skipping self-cancellation: running event matches new event"); + continue; + } + if (shouldIgnoreEvent(newEvent, policy, runningChangeBasedEvent, trigger)) { logger.debug("Ignoring event based on policy"); continue; From c38cbe9c815f7894b8fe9596c46d563d3b308227 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 2 Jun 2026 15:49:01 +0200 Subject: [PATCH 38/87] Fixing hazelcast initialization --- .../plugins/gerrit/trigger/PluginImpl.java | 21 ++++++++++++------- .../HazelcastBuildMemoryStorage.java | 2 +- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java index 8e3a817bb..d5633a704 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java @@ -699,20 +699,25 @@ private void initializeCoordinationProviders() { logger.debug("Configured coordination mode: {}", configuredMode); // Try to initialize the configured (non-local) provider first. + // Each provider's initialize() internally checks the configured mode and is a no-op + // if the mode doesn't match, so we try non-local providers in priority order. // Local is handled separately below as the explicit fallback. if (!"local".equalsIgnoreCase(configuredMode)) { for (CoordinationModeProvider provider : providers) { - if (provider.getModeName().equalsIgnoreCase(configuredMode)) { - try { - logger.info("Initializing coordination provider: {}", provider.getModeName()); - provider.initialize(); + if (provider instanceof LocalCoordinationProvider) { + continue; + } + try { + logger.info("Initializing coordination provider: {}", provider.getModeName()); + provider.initialize(); + if (provider.isAvailable()) { logger.info("Provider {} initialized successfully", provider.getModeName()); return; - } catch (Exception e) { - logger.warn("Failed to initialize {} coordination provider. Falling back to Local.", - provider.getModeName(), e); - break; } + } catch (Exception e) { + logger.warn("Failed to initialize {} coordination provider. Falling back to Local.", + provider.getModeName(), e); + break; } } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 8460cbeac..58d69c2a7 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -194,7 +194,7 @@ private void registerAbortInboxListener() { String buildId = abortKey.substring(lastColon + 1); String causeType = event.getValue(); handleAbortRequest(jobName, buildId, causeType); - }, false); + }, true); // includeValue=true: the cause type string is needed by handleAbortRequest logger.debug("Registered abort-inbox listener on map: {}", ABORT_INBOX_MAP_NAME); } From 121a26dd820ca574e8dc814027920d0dc91d2caf Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Wed, 3 Jun 2026 09:39:23 +0200 Subject: [PATCH 39/87] Fixing race condition problem in the aborting scenario --- .../HazelcastBuildMemoryStorage.java | 98 +++++++++++++++++-- 1 file changed, 90 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 58d69c2a7..7cc9ecdb3 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -38,6 +38,7 @@ import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.NewPatchSetInterruption; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.BuildMemoryStorage; import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; +import hudson.model.Computer; import hudson.model.Executor; import hudson.model.Job; import hudson.model.Result; @@ -46,6 +47,7 @@ import jenkins.model.CauseOfInterruption; import hudson.security.ACLContext; import jenkins.model.Jenkins; +import jenkins.util.Timer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -138,6 +140,19 @@ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { */ private static final String CAUSE_ABANDONED = "ABANDONED"; + /** + * Delay in seconds before writing a deferred cross-replica abort inbox entry. + *

          + * When {@code started()} detects that an entry is already marked {@code isCancelling=true} + * (race condition: build started after the abort decision was made), we write to the abort + * inbox to notify other replicas. However, firing the interrupt immediately during CPS + * pipeline initialization has no effect — the CPS execution thread has not yet started + * executing steps, so the interrupt flag is lost. A short delay ensures the pipeline has + * time to finish initialization and block in its first {@code sleep()} or similar step + * before the interrupt is delivered. + */ + private static final long DEFERRED_ABORT_DELAY_SECONDS = 3L; + /** * Gson instance for JSON serialization of events. * Configured to handle polymorphic event types by including runtime type information. @@ -227,16 +242,49 @@ private static void handleAbortRequest(String jobName, String buildId, String ca if (build == null || !build.isBuilding()) { return; } - Executor executor = build.getExecutor(); - if (executor != null) { - CauseOfInterruption cause; - if (CAUSE_ABANDONED.equals(causeType)) { - cause = new AbandonedPatchsetInterruption(); - } else { - cause = new NewPatchSetInterruption(); + + // Ensure the build has been running long enough for CPS pipeline initialization + // to complete. Interrupting a Pipeline build during CPS initialization (first few + // seconds) has no effect: the CPS execution thread is still setting up the program + // state and has not yet entered a step that responds to executor.interrupt(). + // If the build is too fresh, schedule a direct retry after the remaining delay. + long buildAgeMs = System.currentTimeMillis() - build.getStartTimeInMillis(); + long minBuildAgeMs = TimeUnit.SECONDS.toMillis(DEFERRED_ABORT_DELAY_SECONDS); + if (buildAgeMs < minBuildAgeMs) { + long retryDelayMs = minBuildAgeMs - buildAgeMs; + logger.info("Abort-inbox: build={}/{} is only {}ms old, retrying in {}ms", + jobName, buildId, buildAgeMs, retryDelayMs); + Timer.get().schedule( + () -> handleAbortRequest(jobName, buildId, causeType), + retryDelayMs, TimeUnit.MILLISECONDS); + return; + } + + CauseOfInterruption cause; + if (CAUSE_ABANDONED.equals(causeType)) { + cause = new AbandonedPatchsetInterruption(); + } else { + cause = new NewPatchSetInterruption(); + } + // Iterate all executors to find the one currently running this build. + // Using build.getExecutor() is unreliable for Pipeline jobs: the flyweight + // executor (controller-side CPS orchestrator) may be parked/suspended while + // the actual work runs on an agent executor. Iterating computers mirrors the + // same approach used in BuildMemory.cancelMatchingJobs() for local cancellation. + boolean interrupted = false; + for (Computer c : jenkins.getComputers()) { + for (Executor e : c.getAllExecutors()) { + if (build.equals(e.getCurrentExecutable())) { + e.interrupt(Result.ABORTED, cause); + interrupted = true; + } } - executor.interrupt(Result.ABORTED, cause); + } + if (interrupted) { logger.info("Abort-inbox: interrupted job={} build={} cause={}", jobName, buildId, causeType); + } else { + logger.debug("Abort-inbox: no executor found for job={} build={} (may not be running locally)", + jobName, buildId); } } catch (Exception e) { logger.error("Abort-inbox: failed to abort job={} build={}", jobName, buildId, e); @@ -454,6 +502,12 @@ public synchronized void started(@NonNull GerritTriggeredEvent event, @NonNull R // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). long startedTimestamp = System.currentTimeMillis(); + // Track whether a deferred cross-replica abort needs to be sent after the lock is released. + // This handles the race condition where requestCrossReplicaAbort() was called before this + // build's buildId was written to Hazelcast (buildId was null at that time, so no abort + // inbox entry was written). We detect this by checking isCancelling on the entry after + // writing the buildId, and then write the abort inbox entry here. + boolean pendingCrossReplicaAbort = false; map.lock(key); try { MemoryImprintData data = map.get(key); @@ -467,6 +521,11 @@ public synchronized void started(@NonNull GerritTriggeredEvent event, @NonNull R entryData.setBuildId(buildId); entryData.setStartedTimestamp(startedTimestamp); found = true; + // If this entry is already marked for cancellation, we need to trigger + // cross-replica abort now that buildId is known. + if (entryData.isCancelling()) { + pendingCrossReplicaAbort = true; + } break; } } @@ -489,6 +548,29 @@ public synchronized void started(@NonNull GerritTriggeredEvent event, @NonNull R } finally { map.unlock(key); } + + // Deferred cross-replica abort: if the entry was already marked for cancellation when this + // build started, requestCrossReplicaAbort() previously found buildId=null and could not + // write the abort inbox entry. Now that buildId is known, schedule the abort signal with + // a short delay so the CPS pipeline has time to complete initialization before the + // interrupt is delivered. Firing the interrupt too early (during CPS init, before any + // step begins) has no effect — the interrupt flag is set on the wrong thread context. + if (pendingCrossReplicaAbort && hazelcastInstance != null) { + final HazelcastInstance hz = hazelcastInstance; + final String abortKey = projectFullName + ":" + buildId; + logger.info("Scheduling deferred cross-replica abort in {}s (started race): job={} build={}", + DEFERRED_ABORT_DELAY_SECONDS, projectFullName, buildId); + Timer.get().schedule(() -> { + try { + IMap abortInbox = hz.getMap(ABORT_INBOX_MAP_NAME); + abortInbox.put(abortKey, CAUSE_NEW_PATCHSET, ABORT_INBOX_TTL_SECONDS, TimeUnit.SECONDS); + logger.info("Queued deferred cross-replica abort (started race): job={} build={} cause={}", + projectFullName, buildId, CAUSE_NEW_PATCHSET); + } catch (Exception ex) { + logger.warn("Failed to write deferred abort inbox entry: key={}", abortKey, ex); + } + }, DEFERRED_ABORT_DELAY_SECONDS, TimeUnit.SECONDS); + } } @Override From cc9f1b5e9e6b259a14de7eb7a7c255ee50d33b4e Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Thu, 4 Jun 2026 08:27:48 +0200 Subject: [PATCH 40/87] Peer review 3 (BuildCompletedProcessor) - removing unused processors --- .../hazelcast/BuildCancelledProcessor.java | 90 ---------- .../hazelcast/BuildCompletedProcessor.java | 94 ----------- .../hazelcast/BuildStartedProcessor.java | 89 ---------- .../HazelcastBuildMemoryStorage.java | 99 +++++++++-- .../hazelcast/RemoveProjectProcessor.java | 89 ---------- .../hazelcast/RetriggeredProcessor.java | 155 ------------------ .../hazelcast/SetCancellingProcessor.java | 81 --------- .../hazelcast/SetCustomUrlProcessor.java | 71 -------- .../SetUnsuccessfulMessageProcessor.java | 71 -------- .../hazelcast/TriggeredProcessor.java | 90 ---------- 10 files changed, 87 insertions(+), 842 deletions(-) delete mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java delete mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCompletedProcessor.java delete mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildStartedProcessor.java delete mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/RemoveProjectProcessor.java delete mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/RetriggeredProcessor.java delete mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCancellingProcessor.java delete mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCustomUrlProcessor.java delete mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetUnsuccessfulMessageProcessor.java delete mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/TriggeredProcessor.java diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java deleted file mode 100644 index 06b0704d6..000000000 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCancelledProcessor.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * The MIT License - * - * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; - -import com.hazelcast.map.EntryProcessor; - -import java.util.Map; - -/** - * Hazelcast EntryProcessor for atomically marking a build as cancelled. - * Executes on the partition owner to prevent race conditions. - * - */ -public class BuildCancelledProcessor implements EntryProcessor { - private static final long serialVersionUID = 1L; - - private final String projectFullName; - private final long timestamp; - - /** - * Constructor. - * - * @param projectFullName the full name of the project - */ - public BuildCancelledProcessor(String projectFullName) { - this.projectFullName = projectFullName; - this.timestamp = System.currentTimeMillis(); - } - - @Override - public Boolean process(Map.Entry entry) { - MemoryImprintData data = entry.getValue(); - - // If no data exists, create it (shouldn't happen) - if (data == null) { - data = new MemoryImprintData(); - } - - // Find and update the entry for this project - boolean found = false; - if (data.getEntries() != null) { - for (EntryData entryData : data.getEntries()) { - if (projectFullName.equals(entryData.getProjectFullName())) { - entryData.setCancelled(true); - entryData.setCancelling(false); // Clear cancelling flag - entryData.setCompletedTimestamp(timestamp); // Set completion timestamp - entryData.setBuildCompleted(true); // Cancelled builds are also completed - found = true; - break; - } - } - } - - // If project not found, add it - if (!found) { - EntryData newEntry = new EntryData(); - newEntry.setProjectFullName(projectFullName); - newEntry.setCancelled(true); - newEntry.setCancelling(false); - newEntry.setCompletedTimestamp(timestamp); // Set completion timestamp - newEntry.setBuildCompleted(true); // Cancelled builds are also completed - data.addEntry(newEntry); - } - - // Save the modified data back atomically - entry.setValue(data); - return found; - } -} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCompletedProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCompletedProcessor.java deleted file mode 100644 index 6646977e8..000000000 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildCompletedProcessor.java +++ /dev/null @@ -1,94 +0,0 @@ -/* - * The MIT License - * - * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; - -import com.hazelcast.map.EntryProcessor; - -import java.util.Map; - -/** - * Hazelcast EntryProcessor for atomically marking a build as completed. - * Executes on the partition owner to prevent race conditions when multiple - * replicas update the same event simultaneously. - * - */ -public class BuildCompletedProcessor implements EntryProcessor { - private static final long serialVersionUID = 1L; - - private final String projectFullName; - private final String buildId; - private final long timestamp; - - /** - * Constructor. - * - * @param projectFullName the full name of the project - * @param buildId the build ID - */ - public BuildCompletedProcessor(String projectFullName, String buildId) { - this.projectFullName = projectFullName; - this.buildId = buildId; - this.timestamp = System.currentTimeMillis(); - } - - @Override - public Boolean process(Map.Entry entry) { - MemoryImprintData data = entry.getValue(); - - // If no data exists, create it (shouldn't happen but handle gracefully) - if (data == null) { - data = new MemoryImprintData(); - } - - // Find and update the entry for this project - boolean found = false; - if (data.getEntries() != null) { - for (EntryData entryData : data.getEntries()) { - if (projectFullName.equals(entryData.getProjectFullName())) { - if (entryData.getBuildId() == null) { - entryData.setBuildId(buildId); - } - entryData.setCompletedTimestamp(timestamp); - entryData.setBuildCompleted(true); - found = true; - break; - } - } - } - - // If project not found, add it (build completed without being registered) - if (!found) { - EntryData newEntry = new EntryData(); - newEntry.setProjectFullName(projectFullName); - newEntry.setBuildId(buildId); - newEntry.setCompletedTimestamp(timestamp); - newEntry.setBuildCompleted(true); - data.addEntry(newEntry); - } - - // Save the modified data back atomically - entry.setValue(data); - return found; - } -} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildStartedProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildStartedProcessor.java deleted file mode 100644 index 23ac4b675..000000000 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildStartedProcessor.java +++ /dev/null @@ -1,89 +0,0 @@ -/* - * The MIT License - * - * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; - -import com.hazelcast.map.EntryProcessor; - -import java.util.Map; - -/** - * Hazelcast EntryProcessor for atomically marking a build as started. - * Executes on the partition owner to prevent race conditions. - * - */ -public class BuildStartedProcessor implements EntryProcessor { - private static final long serialVersionUID = 1L; - - private final String projectFullName; - private final String buildId; - private final long timestamp; - - /** - * Constructor. - * - * @param projectFullName the full name of the project - * @param buildId the build ID - */ - public BuildStartedProcessor(String projectFullName, String buildId) { - this.projectFullName = projectFullName; - this.buildId = buildId; - this.timestamp = System.currentTimeMillis(); - } - - @Override - public Boolean process(Map.Entry entry) { - MemoryImprintData data = entry.getValue(); - - // If no data exists, create it (build started without being triggered) - if (data == null) { - data = new MemoryImprintData(); - } - - // Find and update the entry for this project - boolean found = false; - if (data.getEntries() != null) { - for (EntryData entryData : data.getEntries()) { - if (projectFullName.equals(entryData.getProjectFullName())) { - entryData.setBuildId(buildId); - entryData.setStartedTimestamp(timestamp); - found = true; - break; - } - } - } - - // If project not found, add it (build started without being triggered) - if (!found) { - EntryData newEntry = new EntryData(); - newEntry.setProjectFullName(projectFullName); - newEntry.setBuildId(buildId); - newEntry.setStartedTimestamp(timestamp); - data.addEntry(newEntry); - } - - // Save the modified data back atomically - entry.setValue(data); - return found; - } -} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 7cc9ecdb3..fd506c5ab 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -64,7 +64,12 @@ * Hazelcast-backed implementation of BuildMemoryStorage for HA/HS deployments. *

          * Uses distributed IMap for storing build memory across multiple Jenkins replicas. - * All operations use atomic EntryProcessor to prevent race conditions. + * All operations use distributed locks ({@code map.tryLock(key, timeout, unit)}) to prevent race conditions. + *

          + * Note on EntryProcessors: EntryProcessors are intentionally not used. In Hazelcast + * client mode the processor class executes on the Hazelcast sidecar JVM, which does not have + * Jenkins or plugin classes on its classpath, causing {@link ClassNotFoundException} at runtime. + * Distributed locks provide equivalent atomicity guarantees without this constraint. *

          * This implementation is automatically selected when: *

            @@ -86,8 +91,8 @@ *
              *
            • Event Serialization: {@link #serializeEvent} converts GerritTriggeredEvent to JSON * using {@link PolymorphicEventTypeAdapter} for type preservation
            • - *
            • Entry Data Extraction: EntryProcessors extract string identifiers (project full names, - * build IDs) from Jenkins objects before storage
            • + *
            • Entry Data Extraction: String identifiers (project full names, build IDs) are + * extracted from Jenkins objects before storage inside distributed-lock sections
            • *
            • Reconstruction: {@link #reconstructMemoryImprint} deserializes JSON to events and * looks up Jenkins objects via {@link jenkins.model.Jenkins#getItemByFullName}
            • *
            @@ -153,6 +158,15 @@ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { */ private static final long DEFERRED_ABORT_DELAY_SECONDS = 3L; + /** + * Maximum time in seconds to wait when acquiring a distributed lock. + *

            + * Using {@link IMap#tryLock(Object, long, TimeUnit)} instead of {@link IMap#lock(Object)} + * prevents threads from blocking indefinitely when a lock is stuck (e.g. after an + * interrupted lock acquisition that left the Hazelcast server holding the lock). + */ + private static final int LOCK_ACQUIRE_TIMEOUT_SECONDS = 10; + /** * Gson instance for JSON serialization of events. * Configured to handle polymorphic event types by including runtime type information. @@ -318,6 +332,31 @@ private IMap getDistributedMemory() { return distributedMemory; } + /** + * Attempts to acquire a distributed lock with a bounded timeout. + *

            + * Unlike {@link IMap#lock(Object)}, this method will not block indefinitely. + * If the lock cannot be acquired within {@link #LOCK_ACQUIRE_TIMEOUT_SECONDS} seconds + * (e.g. a previous holder was interrupted mid-operation and left the lock unreleased), + * this method returns {@code false} so the caller can skip the operation rather than + * deadlocking a Gerrit event worker thread. + *

            + * If interrupted while waiting, the thread's interrupt status is restored before returning. + * + * @param map the distributed map that owns the lock + * @param key the key to lock + * @return {@code true} if the lock was acquired, {@code false} otherwise + */ + private static boolean tryLockWithTimeout(IMap map, String key) { + try { + return map.tryLock(key, LOCK_ACQUIRE_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.warn("Interrupted while waiting for distributed lock on key: {}", key); + return false; + } + } + /** * Serializes a GerritTriggeredEvent to JSON. * @@ -453,7 +492,11 @@ public synchronized void triggered(@NonNull GerritTriggeredEvent event, @NonNull // causing some project entries to be lost from BuildMemory (which breaks cancellation logic). // Note: EntryProcessor is NOT used here because in client mode the processor class would need // to exist on the Hazelcast sidecar member's classpath, causing ClassNotFoundException. - map.lock(key); + if (!tryLockWithTimeout(map, key)) { + logger.error("Could not acquire distributed lock for key {} within {}s - skipping triggered()", + key, LOCK_ACQUIRE_TIMEOUT_SECONDS); + return; + } try { MemoryImprintData data = map.get(key); if (data == null) { @@ -508,7 +551,11 @@ public synchronized void started(@NonNull GerritTriggeredEvent event, @NonNull R // inbox entry was written). We detect this by checking isCancelling on the entry after // writing the buildId, and then write the abort inbox entry here. boolean pendingCrossReplicaAbort = false; - map.lock(key); + if (!tryLockWithTimeout(map, key)) { + logger.error("Could not acquire distributed lock for key {} within {}s - skipping started()", + key, LOCK_ACQUIRE_TIMEOUT_SECONDS); + return; + } try { MemoryImprintData data = map.get(key); if (data == null) { @@ -587,7 +634,11 @@ public synchronized void completed(@NonNull GerritTriggeredEvent event, @NonNull // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). long completedTimestamp = System.currentTimeMillis(); - map.lock(key); + if (!tryLockWithTimeout(map, key)) { + logger.error("Could not acquire distributed lock for key {} within {}s - skipping completed()", + key, LOCK_ACQUIRE_TIMEOUT_SECONDS); + return; + } try { MemoryImprintData data = map.get(key); if (data == null) { @@ -642,7 +693,11 @@ public synchronized void retriggered(@NonNull GerritTriggeredEvent event, @NonNu String eventJson = serializeEvent(event); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). - map.lock(key); + if (!tryLockWithTimeout(map, key)) { + logger.error("Could not acquire distributed lock for key {} within {}s - skipping retriggered()", + key, LOCK_ACQUIRE_TIMEOUT_SECONDS); + return; + } try { MemoryImprintData data = map.get(key); if (data == null) { @@ -699,7 +754,11 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). long cancelledTimestamp = System.currentTimeMillis(); - map.lock(key); + if (!tryLockWithTimeout(map, key)) { + logger.error("Could not acquire distributed lock for key {} within {}s - skipping cancelled()", + key, LOCK_ACQUIRE_TIMEOUT_SECONDS); + return; + } try { MemoryImprintData data = map.get(key); if (data == null) { @@ -764,7 +823,11 @@ public synchronized void setCancelling(@NonNull GerritTriggeredEvent event, @Non String projectFullName = project.getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). - map.lock(key); + if (!tryLockWithTimeout(map, key)) { + logger.error("Could not acquire distributed lock for key {} within {}s - skipping setCancelling()", + key, LOCK_ACQUIRE_TIMEOUT_SECONDS); + return; + } try { MemoryImprintData data = map.get(key); if (data != null && data.getEntries() != null) { @@ -866,7 +929,11 @@ public synchronized void removeProject(@NonNull Job project) { java.util.Set keys = new java.util.HashSet<>(map.keySet()); for (String key : keys) { - map.lock(key); + if (!tryLockWithTimeout(map, key)) { + logger.error("Could not acquire distributed lock for key {} within {}s - skipping removeProject() entry", + key, LOCK_ACQUIRE_TIMEOUT_SECONDS); + continue; + } try { MemoryImprintData data = map.get(key); if (data == null || data.getEntries() == null) { @@ -996,7 +1063,11 @@ public void setEntryCustomUrl(@NonNull GerritTriggeredEvent event, @NonNull Run String projectFullName = r.getParent().getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). - map.lock(key); + if (!tryLockWithTimeout(map, key)) { + logger.error("Could not acquire distributed lock for key {} within {}s - skipping setEntryCustomUrl()", + key, LOCK_ACQUIRE_TIMEOUT_SECONDS); + return; + } try { MemoryImprintData data = map.get(key); if (data == null || data.getEntries() == null) { @@ -1038,7 +1109,11 @@ public void setEntryUnsuccessfulMessage(@NonNull GerritTriggeredEvent event, @No String projectFullName = r.getParent().getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). - map.lock(key); + if (!tryLockWithTimeout(map, key)) { + logger.error("Could not acquire distributed lock for key {} within {}s" + + " - skipping setEntryUnsuccessfulMessage()", key, LOCK_ACQUIRE_TIMEOUT_SECONDS); + return; + } try { MemoryImprintData data = map.get(key); if (data == null || data.getEntries() == null) { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/RemoveProjectProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/RemoveProjectProcessor.java deleted file mode 100644 index 06f4c1d48..000000000 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/RemoveProjectProcessor.java +++ /dev/null @@ -1,89 +0,0 @@ -/* - * The MIT License - * - * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; - -import com.hazelcast.map.EntryProcessor; - -import java.io.Serializable; -import java.util.Map; - -/** - * Hazelcast EntryProcessor for atomic project removal operation. - *

            - * Removes all EntryData entries matching the specified project name from - * a single MemoryImprintData entry. If after removal the MemoryImprintData - * becomes empty (no entries left), it signals for map entry deletion by - * returning {@code true}. - *

            - * This processor ensures atomicity - even if multiple replicas attempt - * concurrent removal operations, the updates won't overwrite each other. - * - * @see HazelcastBuildMemoryStorage#removeProject - */ -public class RemoveProjectProcessor implements EntryProcessor, - Serializable { - - private static final long serialVersionUID = 1L; - - private final String projectFullName; - - /** - * Constructor for RemoveProjectProcessor. - * - * @param projectFullName the full name of the project to remove - */ - public RemoveProjectProcessor(String projectFullName) { - this.projectFullName = projectFullName; - } - - @Override - public Boolean process(Map.Entry entry) { - MemoryImprintData data = entry.getValue(); - - if (data == null || data.getEntries() == null) { - // No data or no entries - nothing to remove - return false; - } - - // Remove matching entries - boolean removed = data.getEntries().removeIf(entryData -> - projectFullName.equals(entryData.getProjectFullName()) - ); - - if (removed) { - // Check if MemoryImprintData is now empty - if (data.getEntries() == null || data.getEntries().isEmpty()) { - // Signal that this map entry should be deleted - return true; - } else { - // Update the entry with modified data - entry.setValue(data); - return false; - } - } - - // Nothing was removed - return false; - } -} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/RetriggeredProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/RetriggeredProcessor.java deleted file mode 100644 index 4d554d2fb..000000000 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/RetriggeredProcessor.java +++ /dev/null @@ -1,155 +0,0 @@ -/* - * The MIT License - * - * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; - -import com.hazelcast.map.EntryProcessor; -import hudson.model.Run; - -import java.io.Serializable; -import java.util.List; -import java.util.Map; - -/** - * Hazelcast EntryProcessor for atomic retriggered operation. - *

            - * Handles the case where a job is retriggered for the same event. - * Resets the retriggered project's build information while preserving - * other builds from the previous trigger context. - *

            - * This processor ensures atomicity - even if multiple replicas attempt - * concurrent retriggered operations, the updates won't overwrite each other. - * - * @see HazelcastBuildMemoryStorage#retriggered - */ -public class RetriggeredProcessor implements EntryProcessor, Serializable { - - private static final long serialVersionUID = 1L; - - private final String projectFullName; - private final String eventJson; - private final List otherBuildsList; - - /** - * Constructor for RetriggeredProcessor. - * - * @param projectFullName the full name of the project being retriggered - * @param eventJson JSON serialization of the event - * @param otherBuilds list of other builds from previous trigger context (can be null) - */ - public RetriggeredProcessor(String projectFullName, String eventJson, List otherBuilds) { - this.projectFullName = projectFullName; - this.eventJson = eventJson; - - // Convert Run objects to serializable BuildInfo - // (Run objects are not serializable, so we extract the needed data) - if (otherBuilds != null && !otherBuilds.isEmpty()) { - this.otherBuildsList = new java.util.ArrayList<>(otherBuilds.size()); - for (Run build : otherBuilds) { - this.otherBuildsList.add(new BuildInfo( - build.getParent().getFullName(), - build.getId(), - !build.isBuilding() - )); - } - } else { - this.otherBuildsList = null; - } - } - - @Override - public Void process(Map.Entry entry) { - MemoryImprintData data = entry.getValue(); - - if (data == null) { - // Create new memory imprint data - data = new MemoryImprintData(); - data.setEventJson(eventJson); - - if (otherBuildsList != null) { - // Populate with old build info - for (BuildInfo buildInfo : otherBuildsList) { - EntryData entryData = new EntryData(); - entryData.setProjectFullName(buildInfo.projectFullName); - entryData.setBuildId(buildInfo.buildId); - entryData.setBuildCompleted(buildInfo.completed); - data.addEntry(entryData); - } - } - } - - // Reset the retriggered project (clear build info) - boolean found = false; - - if (data.getEntries() != null) { - for (EntryData entryData : data.getEntries()) { - if (projectFullName.equals(entryData.getProjectFullName())) { - // Reset this entry - entryData.setBuildId(null); - entryData.setBuildCompleted(false); - entryData.setStartedTimestamp(null); - entryData.setCompletedTimestamp(null); - found = true; - break; - } - } - } - - if (!found) { - // Add new entry for retriggered project - EntryData entryData = new EntryData(); - entryData.setProjectFullName(projectFullName); - data.addEntry(entryData); - } - - // Update the entry value - entry.setValue(data); - - return null; - } - - /** - * Serializable wrapper for Build information. - * Used to transfer build data across Hazelcast cluster without serializing Run objects. - */ - private static class BuildInfo implements Serializable { - private static final long serialVersionUID = 1L; - - private final String projectFullName; - private final String buildId; - private final boolean completed; - - /** - * Constructor for BuildInfo. - * - * @param projectFullName the full name of the project - * @param buildId the build ID - * @param completed true if the build is completed - */ - BuildInfo(String projectFullName, String buildId, boolean completed) { - this.projectFullName = projectFullName; - this.buildId = buildId; - this.completed = completed; - } - } -} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCancellingProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCancellingProcessor.java deleted file mode 100644 index 03f8403f8..000000000 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCancellingProcessor.java +++ /dev/null @@ -1,81 +0,0 @@ -/* - * The MIT License - * - * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; - -import com.hazelcast.map.EntryProcessor; - -import java.util.Map; - -/** - * Hazelcast Entry Processor to atomically set the "cancelling" flag for a project entry. - *

            - * This processor is used when the build cancellation policy decides a build should be cancelled, - * marking the intent before Jenkins actually processes the cancellation. - * The "cancelling" flag prevents the same build from being reconsidered for cancellation - * in future policy checks. - *

            - * Thread-safe atomic operation. - */ -public class SetCancellingProcessor implements EntryProcessor { - - private static final long serialVersionUID = 1L; - - private final String projectFullName; - - /** - * Constructor. - * - * @param projectFullName the full name of the project being marked for cancellation - */ - public SetCancellingProcessor(String projectFullName) { - this.projectFullName = projectFullName; - } - - @Override - public Object process(Map.Entry entry) { - MemoryImprintData data = entry.getValue(); - if (data == null) { - return null; - } - - // Find the entry for the project and set cancelling flag - boolean updated = false; - for (EntryData entryData : data.getEntries()) { - if (projectFullName.equals(entryData.getProjectFullName())) { - // Only set cancelling if not already completed, cancelling, or cancelled - if (!entryData.isBuildCompleted() && !entryData.isCancelling() && !entryData.isCancelled()) { - entryData.setCancelling(true); - updated = true; - } - } - } - - // Save changes if we updated anything - if (updated) { - entry.setValue(data); - } - - return null; - } -} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCustomUrlProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCustomUrlProcessor.java deleted file mode 100644 index 79eca757e..000000000 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetCustomUrlProcessor.java +++ /dev/null @@ -1,71 +0,0 @@ -/* - * The MIT License - * - * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; - -import com.hazelcast.map.EntryProcessor; - -import java.util.Map; - -/** - * Hazelcast EntryProcessor for atomically setting a custom URL for a build. - * Executes on the partition owner to prevent race conditions. - * - */ -public class SetCustomUrlProcessor implements EntryProcessor { - private static final long serialVersionUID = 1L; - - private final String projectFullName; - private final String customUrl; - - /** - * Constructor. - * - * @param projectFullName the full name of the project - * @param customUrl the custom URL to set - */ - public SetCustomUrlProcessor(String projectFullName, String customUrl) { - this.projectFullName = projectFullName; - this.customUrl = customUrl; - } - - @Override - public Boolean process(Map.Entry entry) { - MemoryImprintData data = entry.getValue(); - if (data == null || data.getEntries() == null) { - return false; - } - - // Find and update the entry for this project - for (EntryData entryData : data.getEntries()) { - if (projectFullName.equals(entryData.getProjectFullName())) { - entryData.setCustomUrl(customUrl); - // Save the modified data back atomically - entry.setValue(data); - return true; - } - } - - return false; - } -} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetUnsuccessfulMessageProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetUnsuccessfulMessageProcessor.java deleted file mode 100644 index a15becdc0..000000000 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/SetUnsuccessfulMessageProcessor.java +++ /dev/null @@ -1,71 +0,0 @@ -/* - * The MIT License - * - * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; - -import com.hazelcast.map.EntryProcessor; - -import java.util.Map; - -/** - * Hazelcast EntryProcessor for atomically setting an unsuccessful message for a build. - * Executes on the partition owner to prevent race conditions. - * - */ -public class SetUnsuccessfulMessageProcessor implements EntryProcessor { - private static final long serialVersionUID = 1L; - - private final String projectFullName; - private final String unsuccessfulMessage; - - /** - * Constructor. - * - * @param projectFullName the full name of the project - * @param unsuccessfulMessage the unsuccessful message to set - */ - public SetUnsuccessfulMessageProcessor(String projectFullName, String unsuccessfulMessage) { - this.projectFullName = projectFullName; - this.unsuccessfulMessage = unsuccessfulMessage; - } - - @Override - public Boolean process(Map.Entry entry) { - MemoryImprintData data = entry.getValue(); - if (data == null || data.getEntries() == null) { - return false; - } - - // Find and update the entry for this project - for (EntryData entryData : data.getEntries()) { - if (projectFullName.equals(entryData.getProjectFullName())) { - entryData.setUnsuccessfulMessage(unsuccessfulMessage); - // Save the modified data back atomically - entry.setValue(data); - return true; - } - } - - return false; - } -} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/TriggeredProcessor.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/TriggeredProcessor.java deleted file mode 100644 index ea8823eb4..000000000 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/TriggeredProcessor.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * The MIT License - * - * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; - -import com.hazelcast.map.EntryProcessor; - -import java.util.Map; - -/** - * Hazelcast EntryProcessor for atomically recording a triggered build. - *

            - * This processor prevents the "lost update" race condition that occurs when multiple - * projects are triggered by the same Gerrit event simultaneously. Without atomic operations, - * concurrent threads can overwrite each other's entries, causing some project entries to be lost - * from BuildMemory. - *

            - * Executes on the partition owner to ensure atomicity across distributed Hazelcast cluster. - * - */ -public class TriggeredProcessor implements EntryProcessor { - private static final long serialVersionUID = 1L; - - private final String projectFullName; - private final String eventJson; - - /** - * Constructor. - * - * @param projectFullName the full name of the project - * @param eventJson the serialized JSON representation of the event - */ - public TriggeredProcessor(String projectFullName, String eventJson) { - this.projectFullName = projectFullName; - this.eventJson = eventJson; - } - - @Override - public Boolean process(Map.Entry entry) { - MemoryImprintData data = entry.getValue(); - - // Create new data if this is the first project triggered by this event - if (data == null) { - data = new MemoryImprintData(); - data.setEventJson(eventJson); - } - - // Check if this project is already recorded (idempotency check) - boolean found = false; - if (data.getEntries() != null) { - for (EntryData entryData : data.getEntries()) { - if (projectFullName.equals(entryData.getProjectFullName())) { - found = true; - break; - } - } - } - - // Add entry for this project if not already present - if (!found) { - EntryData newEntry = new EntryData(); - newEntry.setProjectFullName(projectFullName); - data.addEntry(newEntry); - } - - // Save the modified data back atomically - entry.setValue(data); - return !found; // Return true if this was a new entry, false if already existed - } -} From 471c06c18a34dfee33997b9edcd3aa0c7ee1c547 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Thu, 4 Jun 2026 12:58:57 +0200 Subject: [PATCH 41/87] Fixing problems in the hazelcast tests --- .../plugins/gerrit/trigger/PluginImpl.java | 7 +++++ .../HazelcastBuildMemoryStorage.java | 30 ++++++------------- .../GerritTriggeredBuildListenerTest.java | 15 +++++++++- ...uildCompletedRestCommandJobHudsonTest.java | 17 +++++++++-- 4 files changed, 45 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java index d5633a704..b5d006d72 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java @@ -24,6 +24,7 @@ */ package com.sonyericsson.hudson.plugins.gerrit.trigger; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.CoordinationModeFactory; import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.LocalCoordinationProvider; import com.sonyericsson.hudson.plugins.gerrit.trigger.dependency.DependencyQueueTaskDispatcher; import com.sonyericsson.hudson.plugins.gerrit.trigger.replication.ReplicationQueueTaskDispatcher; @@ -611,6 +612,12 @@ public void start() { // because provider.isAvailable() may check if resources are initialized initializeCoordinationProviders(); + // Eagerly initialize CoordinationModeFactory so discoverMode() runs now (during startup) + // rather than lazily on first event — deferred initialization can add several seconds of + // latency to the first build trigger when ExtensionList.lookup() is called from a + // background event-processing thread. + CoordinationModeFactory.get().getStorage(); + // Wait for Hazelcast cluster to reach the expected member count before connecting to Gerrit. // Without this, events received during the startup window bypass the distributed claim mechanism // and cause duplicate builds across replicas. diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index fd506c5ab..4ed227cc8 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -770,32 +770,20 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull for (EntryData entryData : data.getEntries()) { if (projectFullName.equals(entryData.getProjectFullName())) { found = true; - // Only mark as completed when our own code explicitly flagged this entry - // for cancellation (setCancelling was called by cancelOutdatedEvents). - // External queue cancellations (e.g. QueueLoadBalancer moving the item to - // another replica) must be skipped: setting completed=true here would cause - // allBuildsCompleted() to call forget(), removing the event from the shared - // IMap before the other replica has a chance to run cancelOutdatedEvents(). - if (entryData.isCancelling()) { - entryData.setCancelled(true); - entryData.setCancelling(false); - entryData.setCompletedTimestamp(cancelledTimestamp); - entryData.setBuildCompleted(true); - modified = true; - } else { - logger.debug("Skipping cancelled() for project={} event={}: " - + "isCancelling=false, buildId={}. Likely external cancellation " - + "(e.g. QueueLoadBalancer); not marking as completed.", - projectFullName, key, entryData.getBuildId()); - } + // Mark as cancelled unconditionally. Load-balanced cancellations + // (QueueLoadBalancer moving items between replicas) are already + // filtered out upstream by GerritQueueListener.isLoadBalancedCancellation() + // before cancelled() is ever called. + entryData.setCancelled(true); + entryData.setCancelling(false); + entryData.setCompletedTimestamp(cancelledTimestamp); + entryData.setBuildCompleted(true); + modified = true; break; } } } if (!found) { - // No entry for this project - skip. If the entry was explicitly cancelled - // (isCancelling was set), it would have been found because setCancelling() - // only updates existing entries. This path is an untracked external cancellation. logger.debug("cancelled() called for untracked project={} event={}: skipping.", projectFullName, key); } diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/extensions/GerritTriggeredBuildListenerTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/extensions/GerritTriggeredBuildListenerTest.java index 28e26b030..37561f45e 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/extensions/GerritTriggeredBuildListenerTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/extensions/GerritTriggeredBuildListenerTest.java @@ -27,6 +27,8 @@ import com.sonyericsson.hudson.plugins.gerrit.trigger.GerritServer; import com.sonyericsson.hudson.plugins.gerrit.trigger.PluginImpl; import com.sonyericsson.hudson.plugins.gerrit.trigger.config.Config; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastInstanceProvider; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastManager; import com.sonyericsson.hudson.plugins.gerrit.trigger.mock.DuplicatesUtil; import com.sonyericsson.hudson.plugins.gerrit.trigger.mock.Setup; import com.sonymobile.tools.gerrit.gerritevents.mock.SshdServerMock; @@ -78,6 +80,17 @@ public class GerritTriggeredBuildListenerTest { * * @throws Exception throw if so. */ + @Before + public void clearHazelcastFlags() { + // Clear Hazelcast notification flags between test methods to prevent claim key collision. + // Test events use a fixed eventCreatedOn timestamp, producing identical claim keys across tests. + if (HazelcastManager.isInitialized()) { + HazelcastInstanceProvider.getInstance() + .getMap("gerrit-trigger-notification-flags") + .clear(); + } + } + @Before public void setUp() throws Exception { SshdServerMock.generateKeyPair(); @@ -123,7 +136,7 @@ public void testListenTriggeredBuild() throws Exception { server.waitForCommand(GERRIT_STREAM_EVENTS, 2000); gerritServer.triggerEvent(Setup.createPatchsetCreated()); - assertTrue("Time out", buildListenerLatch.await(15, TimeUnit.SECONDS)); + assertTrue("Time out", buildListenerLatch.await(30, TimeUnit.SECONDS)); } /** diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/job/rest/BuildCompletedRestCommandJobHudsonTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/job/rest/BuildCompletedRestCommandJobHudsonTest.java index 92b95fb72..e3a201f3c 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/job/rest/BuildCompletedRestCommandJobHudsonTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/job/rest/BuildCompletedRestCommandJobHudsonTest.java @@ -27,6 +27,8 @@ import com.sonyericsson.hudson.plugins.gerrit.trigger.GerritServer; import com.sonyericsson.hudson.plugins.gerrit.trigger.PluginImpl; import com.sonyericsson.hudson.plugins.gerrit.trigger.config.Config; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastInstanceProvider; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastManager; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.GerritTrigger; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.Branch; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.CompareType; @@ -77,6 +79,13 @@ public class BuildCompletedRestCommandJobHudsonTest { @Before public void unlockInstance() throws Exception { Setup.unLock(j); + // Clear Hazelcast notification flags between test methods to prevent claim key collision. + // Test events use a fixed eventCreatedOn timestamp, producing identical claim keys across tests. + if (HazelcastManager.isInitialized()) { + HazelcastInstanceProvider.getInstance() + .getMap("gerrit-trigger-notification-flags") + .clear(); + } } /** @@ -193,8 +202,12 @@ public String getUrlName() { * @throws IOException if so. */ public void doDynamic(StaplerRequest request, StaplerResponse response) throws IOException { - lastPath = request.getRestOfPath(); - lastContent = IOUtils.toString(request.getReader()); + String path = request.getRestOfPath(); + // Only track review calls, not plugin-availability checks that may race with the assertion + if (!path.startsWith("/plugins/")) { + lastPath = path; + lastContent = IOUtils.toString(request.getReader()); + } response.setContentType("application/json"); PrintWriter writer = response.getWriter(); From 9d9d2e7178f7ba15b7cf683273e6c26200e647b9 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Mon, 8 Jun 2026 13:59:26 +0200 Subject: [PATCH 42/87] Peer review (EventIdentifier.java) - Reverted changes to get event hash --- .../hazelcast/EventIdentifier.java | 65 ++----------------- 1 file changed, 7 insertions(+), 58 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java index c6ddd0864..a44fea89f 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java @@ -55,21 +55,6 @@ public final class EventIdentifier { */ private static final int SHORT_REVISION_LENGTH = 8; - /** - * Initial prime number for hash computation (standard Java hashCode practice). - */ - private static final int HASH_INITIAL_PRIME = 17; - - /** - * Multiplier prime number for hash computation (standard Java hashCode practice). - */ - private static final int HASH_MULTIPLIER_PRIME = 31; - - /** - * Number of bits to shift for long-to-int hash conversion. - */ - private static final int HASH_LONG_SHIFT_BITS = 32; - /** * Private constructor to prevent instantiation. */ @@ -185,12 +170,13 @@ private static String generateRefUpdatedEventId(RefUpdated event) { /** * Generates fallback ID for events that don't match known patterns. *

            - * Uses only deterministic fields to ensure the same event produces the same ID - * across all replicas. Specifically avoids {@code hashCode()} which is not stable - * across JVMs. + * Uses {@code event.hashCode()} for uniqueness. All events in the gerrit-events library + * implement their own content-based {@code hashCode()}, so this is stable across replicas. + * If a specific event type has a weak {@code hashCode()}, the fix belongs in the + * gerrit-events library. * * @param event the event - * @return event ID in format: event-{type}-{server}-{timestamp}-{deterministicHash} + * @return event ID in format: event-{type}-{server}-{timestamp}-{hash} */ private static String generateFallbackEventId(GerritTriggeredEvent event) { // Use server-side timestamp (eventCreatedOn) for consistency across replicas @@ -203,49 +189,12 @@ private static String generateFallbackEventId(GerritTriggeredEvent event) { serverName = sanitize(event.getProvider().getName()); } - // Create deterministic hash from event fields (not object hashCode!) - int deterministicHash = computeDeterministicHash(event); - - // Format: event---- - // All components are deterministic across replicas + // Format: event---- return String.format("event-%s-%s-%d-%08x", sanitizeEventType(event.getEventType().getTypeValue()), serverName, timestamp, - deterministicHash); - } - - /** - * Computes a deterministic hash from event fields. - *

            - * This hash is stable across JVMs because it's computed from the event's actual - * field values, not from the object's identity or {@code hashCode()}. - *

            - * Uses the same fields that would typically be in a well-implemented {@code hashCode()}: - * event type and timestamp. The provider name is included in the event ID directly, - * so doesn't need to be part of the hash. - * - * @param event the event - * @return deterministic hash value - */ - private static int computeDeterministicHash(GerritTriggeredEvent event) { - int result = HASH_INITIAL_PRIME; // Start with prime number - - // Use event type (always available) - if (event.getEventType() != null && event.getEventType().getTypeValue() != null) { - result = HASH_MULTIPLIER_PRIME * result + event.getEventType().getTypeValue().hashCode(); - } - - // Use timestamp (already deterministic across replicas) - long timestamp = getEventTimestamp(event); - result = HASH_MULTIPLIER_PRIME * result + (int)(timestamp ^ (timestamp >>> HASH_LONG_SHIFT_BITS)); - - // Use server name if available - if (event.getProvider() != null && event.getProvider().getName() != null) { - result = HASH_MULTIPLIER_PRIME * result + event.getProvider().getName().hashCode(); - } - - return result; + event.hashCode()); } /** From 8d170cb2fd07a854eeef8c271565e805149d5d79 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Mon, 8 Jun 2026 15:49:36 +0200 Subject: [PATCH 43/87] Peer review (HazelcastBuildMemoryStorage.java) - removing wrong javadoc --- .../coordination/hazelcast/HazelcastBuildMemoryStorage.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 4ed227cc8..94057b990 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -91,8 +91,6 @@ *

              *
            • Event Serialization: {@link #serializeEvent} converts GerritTriggeredEvent to JSON * using {@link PolymorphicEventTypeAdapter} for type preservation
            • - *
            • Entry Data Extraction: String identifiers (project full names, build IDs) are - * extracted from Jenkins objects before storage inside distributed-lock sections
            • *
            • Reconstruction: {@link #reconstructMemoryImprint} deserializes JSON to events and * looks up Jenkins objects via {@link jenkins.model.Jenkins#getItemByFullName}
            • *
            From 87b83e15712f2f764674a6e09541ecb9c9159526 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 9 Jun 2026 12:52:26 +0200 Subject: [PATCH 44/87] Updating README.md file with the new development information --- README.md | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/README.md b/README.md index f0cd014ee..9815bd665 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,107 @@ Run checkstyle mvn checkstyle:checkstyle +# High Availability / High Scalability (HA/HS) Support + +The plugin supports active/active HA/HS deployments where two or more Jenkins replicas +run in parallel. When enabled, a Hazelcast cluster coordinates the replicas so that: + +- Each Gerrit event is processed by **exactly one** replica (event claiming) +- Build state is shared across replicas (distributed build memory) +- Gerrit feedback (votes and comments) is sent **exactly once** per build event + +By default the plugin runs in **local mode** and requires no additional configuration. +Local mode is fully backward-compatible with single-instance Jenkins deployments. + +Hazelcast mode is activated via a JVM system property. The plugin auto-discovers +cluster peers using Kubernetes service discovery (when running in Kubernetes) or a +static TCP/IP member list. + + +## Configuration Properties + +All HA/HS settings are controlled by JVM system properties passed to Jenkins on startup. + +| Property | Default | Description | +|---|---|---| +| `gerrit.trigger.coordination.mode` | `local` | Set to `hazelcast` to enable HA/HS coordination | +| `gerrit.trigger.coordination.hazelcast.instance.mode` | `member` | `member` (embedded cluster member) or `client` (connect to a Hazelcast sidecar container) | +| `gerrit.trigger.coordination.hazelcast.client.addresses` | `localhost:5702` | Comma-separated `host:port` list of sidecar addresses (client mode only) | +| `gerrit.trigger.coordination.hazelcast.client.cluster.name` | `gerrit-trigger-cluster` | Cluster name to connect to in client mode | +| `gerrit.trigger.coordination.hazelcast.cluster.name` | `gerrit-trigger-cluster` | Cluster name used when running as an embedded member | +| `gerrit.trigger.coordination.hazelcast.port` | `5702` | Hazelcast member port (member mode only) | +| `gerrit.trigger.coordination.hazelcast.port.count` | `10` | Number of ports to try when auto-incrementing | +| `gerrit.trigger.coordination.hazelcast.discovery.mode` | `auto` | Discovery mechanism: `kubernetes`, `tcp`, or `multicast` (testing only) | +| `gerrit.trigger.coordination.hazelcast.k8s.service.name` | `jenkins` | Kubernetes service name used for peer discovery | +| `gerrit.trigger.coordination.hazelcast.k8s.namespace` | `default` | Kubernetes namespace for peer discovery | +| `gerrit.trigger.coordination.hazelcast.tcp.members` | — | Static member list for TCP discovery, e.g. `replica-0.jenkins:5702,replica-1.jenkins:5702` | +| `gerrit.trigger.coordination.hazelcast.operation.timeout` | `30000` | Distributed operation timeout in milliseconds | + +Port `5702` is used by default to avoid conflicts with CloudBees Core CI's internal +Hazelcast cluster, which occupies port `5701`. + + +## Configuration Examples + +### Kubernetes — Client Mode with Hazelcast Sidecar (Recommended) + +In Kubernetes HA/HS deployments the recommended setup runs a Hazelcast container as a +sidecar in each Jenkins pod. The plugin connects to it as a lightweight client, reusing +the cross-pod cluster the sidecar maintains. + +Add the following JVM arguments to the Jenkins controller: + + -Dgerrit.trigger.coordination.mode=hazelcast + -Dgerrit.trigger.coordination.hazelcast.instance.mode=client + -Dgerrit.trigger.coordination.hazelcast.client.addresses=localhost:5702 + -Dgerrit.trigger.coordination.hazelcast.client.cluster.name=gerrit-trigger-cluster + +Add the sidecar container to the controller pod spec: + +```yaml +- name: hazelcast + image: hazelcast/hazelcast:5.3.8 + ports: + - containerPort: 5702 + name: hazelcast + env: + - name: JAVA_OPTS + value: >- + -Dhazelcast.config=/dev/stdin + -Dhazelcast.local.publicAddress=$(POD_IP):5702 + - name: HZ_CLUSTERNAME + value: gerrit-trigger-cluster + - name: HZ_NETWORK_PORT_PORT + value: "5702" +``` + +Grant the pod's service account read access to Kubernetes endpoints so Hazelcast can +discover its peers: + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: hazelcast-gerrit-trigger +rules: + - apiGroups: [""] + resources: ["endpoints", "pods", "nodes", "services"] + verbs: ["get", "list"] + - apiGroups: ["discovery.k8s.io"] + resources: ["endpointslices"] + verbs: ["get", "list"] +``` + + +### TCP/IP — Static Member List (Non-Kubernetes HA) + +For HA deployments outside Kubernetes, configure a static list of member addresses: + + -Dgerrit.trigger.coordination.mode=hazelcast + -Dgerrit.trigger.coordination.hazelcast.discovery.mode=tcp + -Dgerrit.trigger.coordination.hazelcast.tcp.members=replica-0.jenkins:5702,replica-1.jenkins:5702 + + # License The MIT License From 80a272ae5d25f874d8a271c6d5bbeac5d436f68c Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 30 Jun 2026 11:32:47 +0200 Subject: [PATCH 45/87] Peer review comments updates and fixing maven dependencies problem in terms of maven version after the merge --- .mvn/extensions.xml | 5 +++ README.md | 16 ++++++++-- .../hazelcast/HazelcastManager.java | 1 + .../trigger/spi/BuildMemoryStorage.java | 32 +++++++++---------- 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml index 9440b1807..9aff702b2 100644 --- a/.mvn/extensions.xml +++ b/.mvn/extensions.xml @@ -4,4 +4,9 @@ git-changelist-maven-extension 1.13 + + org.jenkins-ci.tools + maven-hpi-plugin + 3.1814.v77d15159f9b_d + diff --git a/README.md b/README.md index 9815bd665..ea32fc206 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,19 @@ run in parallel. When enabled, a Hazelcast cluster coordinates the replicas so t By default the plugin runs in **local mode** and requires no additional configuration. Local mode is fully backward-compatible with single-instance Jenkins deployments. -Hazelcast mode is activated via a JVM system property. The plugin auto-discovers -cluster peers using Kubernetes service discovery (when running in Kubernetes) or a -static TCP/IP member list. +Hazelcast mode is activated via a JVM system property. The plugin supports two instance +modes: **member** (an embedded Hazelcast node runs inside Jenkins, forming a cluster with +other replicas) and **client** (Jenkins connects as a lightweight client to a Hazelcast +sidecar container). In `auto` discovery mode the plugin detects its environment at startup: +if running in Kubernetes it uses service-based peer discovery; otherwise it falls back to +a static TCP/IP member list. See the deployment sections below for concrete configuration +examples. + +Alternative coordination backends can be implemented by extending +[`CoordinationModeProvider`](src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java) +— a Jenkins `ExtensionPoint` that wires together the storage, event-claiming, and +notification-claiming strategies for a given coordination mode. A higher `@Extension` +ordinal takes precedence over the built-in Hazelcast provider. ## Configuration Properties diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java index ec968fdcf..aa6c6a4a1 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java @@ -41,6 +41,7 @@ public final class HazelcastManager { private static final Logger logger = LoggerFactory.getLogger(HazelcastManager.class); + // TODO: This is still a bit dangerous to do and should try to be avoided. But I guess hazelcast itself is already a static field in itself so perhaps nothing can be done? private static volatile boolean initialized = false; private static final Object INIT_LOCK = new Object(); diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java index 86a9fa8fd..3ce1c0017 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java @@ -312,27 +312,25 @@ public void requestCrossReplicaAbort(@NonNull GerritTriggeredEvent event, @NonNu * Checks if two events are logically equivalent for cancellation purposes. *

            * This method allows each storage implementation to define its own event equality - * semantics. This is critical for proper operation in different coordination modes: + * semantics. Both modes use logical comparison, but differ in their approach: *

              - *
            • Local mode: Use logical equality so that deserialized - * event instances (e.g. GerritCause.tEvent loaded from disk) are correctly - * matched against in-memory events. Most trigger events extend ChangeBasedEvent, - * whose equals() compares eventType, change, and patchSet fields — all stable - * across serialization boundaries. Note that event classes - * implement logical {@code .equals()} (see - * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.GerritCause} and - * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.BadgeAction}), - * which is used for TreeMap key lookup. The identity check here is purely for - * performance in cancellation logic.
            • - *
            • Distributed mode: Uses logical comparison via EventIdentifier - * since events are serialized/deserialized across replicas and object identity - * is lost.
            • + *
            • Local mode: Delegates to {@code ChangeBasedEvent.equals()}, + * which compares {@code eventType}, {@code change}, and {@code patchSet} fields. + * This deliberately ignores timestamp so that deserialized event instances + * (e.g. {@code GerritCause.tEvent} loaded from disk) correctly match against + * in-memory events representing the same logical change.
            • + *
            • Distributed mode: Uses {@link EventIdentifier#generateEventId}, + * which produces a deterministic string key including the server-side timestamp. + * The timestamp is needed because the IMap key must uniquely identify each + * event reception across replicas; without it, two different events on the + * same patchset could collide.
            • *
            *

            * Design rationale: Event equality semantics belong in the storage - * layer, not in business logic (BuildMemory). This respects the abstraction boundary - * and allows future coordination modes to define their own comparison strategy without - * modifying BuildMemory. + * layer, not in business logic + * ({@link com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory}). + * This respects the abstraction boundary and allows future coordination modes to + * define their own comparison strategy without modifying BuildMemory. * * @param event1 the first event * @param event2 the second event From 9c792d43aed76c24087d411cc40adb2dbb939948 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 30 Jun 2026 12:47:26 +0200 Subject: [PATCH 46/87] Fixing junit tests --- .../coordination/hazelcast/HazelcastManager.java | 3 ++- .../coordination/hazelcast/HazelcastTestRule.java | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java index aa6c6a4a1..24be5bb34 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java @@ -41,7 +41,8 @@ public final class HazelcastManager { private static final Logger logger = LoggerFactory.getLogger(HazelcastManager.class); - // TODO: This is still a bit dangerous to do and should try to be avoided. But I guess hazelcast itself is already a static field in itself so perhaps nothing can be done? + // TODO: This is still a bit dangerous to do and should try to be avoided. + // But hazelcast itself is already a static field, so perhaps nothing can be done? private static volatile boolean initialized = false; private static final Object INIT_LOCK = new Object(); diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestRule.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestRule.java index 686a0426a..38fae9dba 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestRule.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestRule.java @@ -85,8 +85,16 @@ public class HazelcastTestRule extends ExternalResource { protected void before() throws Exception { logger.info("=== Hazelcast Test Setup START ==="); + // Skip if not running with -Ptest-hazelcast. CoordinationModeFactory is initialised + // by JenkinsRule (which runs before this rule despite field order), so Hazelcast mode + // can only be active if the property was set at JVM startup via the Maven profile. + String preconfiguredMode = System.getProperty(COORDINATION_MODE_PROPERTY); + org.junit.Assume.assumeTrue( + "Skipping Hazelcast integration test - run with -Ptest-hazelcast to enable", + HAZELCAST_MODE.equalsIgnoreCase(preconfiguredMode)); + // Save original property value - originalModeValue = System.getProperty(COORDINATION_MODE_PROPERTY); + originalModeValue = preconfiguredMode; logger.info("Original coordination mode: {}", originalModeValue); // Set coordination mode to hazelcast From c69e3bd36e92a353d9b49fe76dae7356864508fd Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Tue, 30 Jun 2026 13:58:10 +0200 Subject: [PATCH 47/87] Comment #3: Smelly approach when waiting an arbitary time fixed --- pom.xml | 6 + .../HazelcastBuildMemoryStorage.java | 68 ++++++++---- .../hazelcast/PipelineAbortHelper.java | 73 ++++++++++++ .../hazelcast/PipelineAbortHelperTest.java | 104 ++++++++++++++++++ 4 files changed, 231 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelper.java create mode 100644 src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelperTest.java diff --git a/pom.xml b/pom.xml index 056e7a714..4b1e50bc2 100644 --- a/pom.xml +++ b/pom.xml @@ -209,6 +209,12 @@ workflow-support test + + org.jenkins-ci.plugins.workflow + workflow-support + tests + test + org.jenkins-ci.plugins diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 94057b990..7bd26a752 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -148,14 +148,19 @@ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { *

            * When {@code started()} detects that an entry is already marked {@code isCancelling=true} * (race condition: build started after the abort decision was made), we write to the abort - * inbox to notify other replicas. However, firing the interrupt immediately during CPS - * pipeline initialization has no effect — the CPS execution thread has not yet started - * executing steps, so the interrupt flag is lost. A short delay ensures the pipeline has - * time to finish initialization and block in its first {@code sleep()} or similar step - * before the interrupt is delivered. + * inbox to notify other replicas after this delay so that the CPS engine has had time to + * attach a {@link org.jenkinsci.plugins.workflow.flow.FlowExecution} before the interrupt + * arrives. This value is a safety-net upper bound; {@link #handleAbortRequest} will fire + * earlier once {@link PipelineAbortHelper#isPipelineNotYetStarted} returns {@code false}. */ private static final long DEFERRED_ABORT_DELAY_SECONDS = 3L; + /** + * Poll interval in milliseconds used by {@link #handleAbortRequest} while waiting for a + * Pipeline build's CPS execution to start before delivering the interrupt. + */ + private static final long ABORT_RETRY_POLL_MS = 250L; + /** * Maximum time in seconds to wait when acquiring a distributed lock. *

            @@ -241,6 +246,18 @@ private void registerAbortInboxListener() { * @param causeType cause type string ({@value #CAUSE_ABANDONED} or {@value #CAUSE_NEW_PATCHSET}) */ private static void handleAbortRequest(String jobName, String buildId, String causeType) { + handleAbortRequest(jobName, buildId, causeType, System.currentTimeMillis()); + } + + /** + * @param jobName full name of the Jenkins job + * @param buildId build number as a string + * @param causeType abort cause type ({@link #CAUSE_NEW_PATCHSET} or {@link #CAUSE_ABANDONED}) + * @param firstAttemptMs wall-clock time of the first attempt, used to enforce the + * {@link #DEFERRED_ABORT_DELAY_SECONDS} safety-net upper bound + */ + private static void handleAbortRequest(String jobName, String buildId, + String causeType, long firstAttemptMs) { try (ACLContext ignored = ACL.as(ACL.SYSTEM)) { Jenkins jenkins = Jenkins.getInstanceOrNull(); if (jenkins == null) { @@ -255,21 +272,32 @@ private static void handleAbortRequest(String jobName, String buildId, String ca return; } - // Ensure the build has been running long enough for CPS pipeline initialization - // to complete. Interrupting a Pipeline build during CPS initialization (first few - // seconds) has no effect: the CPS execution thread is still setting up the program - // state and has not yet entered a step that responds to executor.interrupt(). - // If the build is too fresh, schedule a direct retry after the remaining delay. - long buildAgeMs = System.currentTimeMillis() - build.getStartTimeInMillis(); - long minBuildAgeMs = TimeUnit.SECONDS.toMillis(DEFERRED_ABORT_DELAY_SECONDS); - if (buildAgeMs < minBuildAgeMs) { - long retryDelayMs = minBuildAgeMs - buildAgeMs; - logger.info("Abort-inbox: build={}/{} is only {}ms old, retrying in {}ms", - jobName, buildId, buildAgeMs, retryDelayMs); - Timer.get().schedule( - () -> handleAbortRequest(jobName, buildId, causeType), - retryDelayMs, TimeUnit.MILLISECONDS); - return; + // For Pipeline builds, wait until the CPS execution has started (i.e. + // FlowExecution.getCurrentHeads() is non-empty) before delivering the interrupt. + // Interrupting during CPS initialisation has no effect — the interrupt flag is + // set before any step is registered, so it is silently lost. + // We poll every ABORT_RETRY_POLL_MS; the safety-net cap of DEFERRED_ABORT_DELAY_SECONDS + // ensures we never wait longer than the previous time-based approach. + boolean notYetStarted; + try { + notYetStarted = PipelineAbortHelper.isPipelineNotYetStarted(build); + } catch (NoClassDefFoundError e) { + // workflow-api not installed — not a pipeline, safe to interrupt now + notYetStarted = false; + } + if (notYetStarted) { + long elapsedMs = System.currentTimeMillis() - firstAttemptMs; + long maxWaitMs = TimeUnit.SECONDS.toMillis(DEFERRED_ABORT_DELAY_SECONDS); + if (elapsedMs < maxWaitMs) { + logger.debug("Abort-inbox: build={}/{} CPS not yet started, retrying in {}ms", + jobName, buildId, ABORT_RETRY_POLL_MS); + Timer.get().schedule( + () -> handleAbortRequest(jobName, buildId, causeType, firstAttemptMs), + ABORT_RETRY_POLL_MS, TimeUnit.MILLISECONDS); + return; + } + logger.info("Abort-inbox: build={}/{} CPS still not started after {}ms, interrupting anyway", + jobName, buildId, elapsedMs); } CauseOfInterruption cause; diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelper.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelper.java new file mode 100644 index 000000000..5c567a4e2 --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelper.java @@ -0,0 +1,73 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import hudson.model.Run; +import org.jenkinsci.plugins.workflow.flow.FlowExecution; +import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner; + +/** + * Optional helper that checks whether a Pipeline build's CPS execution has started. + *

            + * Isolated in its own class so that {@code workflow-api} classes are only loaded when + * the workflow plugin is present. Callers must guard with a {@code try/catch} for + * {@link NoClassDefFoundError} or check plugin availability before calling. + *

            + * Interrupting a Pipeline build during CPS initialisation (before any step has started) + * has no effect — the interrupt flag is silently lost. This helper detects whether the + * CPS program has advanced past initialisation by checking + * {@link FlowExecution#getCurrentHeads()}: an empty list means no {@link + * org.jenkinsci.plugins.workflow.graph.FlowNode} has been created yet, i.e. the pipeline + * has not started executing steps. + */ +final class PipelineAbortHelper { + + private PipelineAbortHelper() { } + + /** + * Returns {@code true} if {@code build} is a Pipeline build whose CPS execution has + * not yet started (i.e. it is still initialising and cannot yet receive an interrupt). + *

            + * Returns {@code false} for non-Pipeline builds or when the flow execution is + * unavailable, both of which are safe to interrupt immediately. + * + * @param build the build to check + * @return true if the build is a pipeline still initialising + */ + static boolean isPipelineNotYetStarted(Run build) { + if (!(build instanceof FlowExecutionOwner.Executable)) { + return false; + } + FlowExecutionOwner owner = ((FlowExecutionOwner.Executable)build).asFlowExecutionOwner(); + if (owner == null) { + return false; + } + FlowExecution execution = owner.getOrNull(); + if (execution == null) { + // Execution not yet attached — CPS is still initialising + return true; + } + return execution.getCurrentHeads().isEmpty(); + } +} diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelperTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelperTest.java new file mode 100644 index 000000000..b95e3ce69 --- /dev/null +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelperTest.java @@ -0,0 +1,104 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import hudson.model.FreeStyleBuild; +import hudson.model.FreeStyleProject; +import hudson.model.Result; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.jenkinsci.plugins.workflow.job.WorkflowRun; +import org.jenkinsci.plugins.workflow.test.steps.SemaphoreStep; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; + +import static org.junit.Assert.assertFalse; + +/** + * Tests for {@link PipelineAbortHelper}. + */ +public class PipelineAbortHelperTest { + + //CS IGNORE VisibilityModifier FOR NEXT 3 LINES. REASON: JUnit Rule. + //CS IGNORE JavadocVariable FOR NEXT 2 LINES. REASON: JUnit Rule. + @Rule + public final JenkinsRule jenkins = new JenkinsRule(); + + /** + * A FreeStyle build is never a Pipeline — should always return false. + */ + @Test + public void testFreeStyleBuildReturnsFalse() throws Exception { + FreeStyleProject project = jenkins.createFreeStyleProject(); + FreeStyleBuild build = project.scheduleBuild2(0).get(); + assertFalse(PipelineAbortHelper.isPipelineNotYetStarted(build)); + } + + /** + * A Pipeline build blocked inside a running step has getCurrentHeads() non-empty, + * so isPipelineNotYetStarted() must return false (safe to interrupt). + */ + @Test + public void testPipelineBlockedAtSemaphoreReturnsFalse() throws Exception { + WorkflowJob job = jenkins.createProject(WorkflowJob.class, "pipeline-semaphore"); + job.setDefinition(new CpsFlowDefinition( + "semaphore 'wait'\n" + + "echo 'done'", true)); + + WorkflowRun run = job.scheduleBuild2(0).waitForStart(); + + // Wait until the semaphore step is reached — at this point CPS is fully started + SemaphoreStep.waitForStart("wait/1", run); + + assertFalse("Pipeline blocked at semaphore should report CPS started", + PipelineAbortHelper.isPipelineNotYetStarted(run)); + + // Unblock and let the build finish cleanly + SemaphoreStep.success("wait/1", null); + jenkins.waitForCompletion(run); + jenkins.assertBuildStatusSuccess(run); + } + + /** + * A Pipeline build that has been interrupted after it started should still + * report false — it is past initialisation, so delivery was correct. + */ + @Test + public void testAbortedPipelineReturnsFalse() throws Exception { + WorkflowJob job = jenkins.createProject(WorkflowJob.class, "pipeline-aborted"); + job.setDefinition(new CpsFlowDefinition( + "semaphore 'wait-abort'\n" + + "echo 'done'", true)); + + WorkflowRun run = job.scheduleBuild2(0).waitForStart(); + SemaphoreStep.waitForStart("wait-abort/1", run); + + // Abort while it's at the semaphore (CPS has started) + assertFalse(PipelineAbortHelper.isPipelineNotYetStarted(run)); + + run.getExecutor().interrupt(Result.ABORTED); + jenkins.waitForCompletion(run); + } +} From 2b24f0125045351c30feacb3df622b59911cc6b7 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Wed, 1 Jul 2026 09:06:36 +0200 Subject: [PATCH 48/87] Fixing Javadoc:jar issues --- .../hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java index 3ce1c0017..c79a29d3d 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java @@ -294,7 +294,7 @@ public abstract void setEntryUnsuccessfulMessage(@NonNull GerritTriggeredEvent e * vs {@link com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.NewPatchSetInterruption}). *

            * This method is called from - * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory#cancelOutdatedBuilds} + * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory#cancelOutdatedEvents} * after {@link #setCancelling} has already marked the entry, so the cause is known at this point. *

            * The default implementation is a no-op — standalone Jenkins has no other replicas to notify. @@ -319,7 +319,7 @@ public void requestCrossReplicaAbort(@NonNull GerritTriggeredEvent event, @NonNu * This deliberately ignores timestamp so that deserialized event instances * (e.g. {@code GerritCause.tEvent} loaded from disk) correctly match against * in-memory events representing the same logical change. - *

          • Distributed mode: Uses {@link EventIdentifier#generateEventId}, + *
          • Distributed mode: Uses {@link com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.EventIdentifier#generateEventId}, * which produces a deterministic string key including the server-side timestamp. * The timestamp is needed because the IMap key must uniquely identify each * event reception across replicas; without it, two different events on the From a21ab4ed7ad966be264d1b3bd3f0aff7b2c2ff15 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Wed, 1 Jul 2026 12:32:31 +0200 Subject: [PATCH 49/87] Splitting README file --- README.md | 114 ++-------------------------------- README_DISTRIBUTED_STORAGE.md | 86 +++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 109 deletions(-) create mode 100644 README_DISTRIBUTED_STORAGE.md diff --git a/README.md b/README.md index ea32fc206..ceab77862 100644 --- a/README.md +++ b/README.md @@ -52,115 +52,11 @@ Run checkstyle mvn checkstyle:checkstyle -# High Availability / High Scalability (HA/HS) Support - -The plugin supports active/active HA/HS deployments where two or more Jenkins replicas -run in parallel. When enabled, a Hazelcast cluster coordinates the replicas so that: - -- Each Gerrit event is processed by **exactly one** replica (event claiming) -- Build state is shared across replicas (distributed build memory) -- Gerrit feedback (votes and comments) is sent **exactly once** per build event - -By default the plugin runs in **local mode** and requires no additional configuration. -Local mode is fully backward-compatible with single-instance Jenkins deployments. - -Hazelcast mode is activated via a JVM system property. The plugin supports two instance -modes: **member** (an embedded Hazelcast node runs inside Jenkins, forming a cluster with -other replicas) and **client** (Jenkins connects as a lightweight client to a Hazelcast -sidecar container). In `auto` discovery mode the plugin detects its environment at startup: -if running in Kubernetes it uses service-based peer discovery; otherwise it falls back to -a static TCP/IP member list. See the deployment sections below for concrete configuration -examples. - -Alternative coordination backends can be implemented by extending -[`CoordinationModeProvider`](src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java) -— a Jenkins `ExtensionPoint` that wires together the storage, event-claiming, and -notification-claiming strategies for a given coordination mode. A higher `@Extension` -ordinal takes precedence over the built-in Hazelcast provider. - - -## Configuration Properties - -All HA/HS settings are controlled by JVM system properties passed to Jenkins on startup. - -| Property | Default | Description | -|---|---|---| -| `gerrit.trigger.coordination.mode` | `local` | Set to `hazelcast` to enable HA/HS coordination | -| `gerrit.trigger.coordination.hazelcast.instance.mode` | `member` | `member` (embedded cluster member) or `client` (connect to a Hazelcast sidecar container) | -| `gerrit.trigger.coordination.hazelcast.client.addresses` | `localhost:5702` | Comma-separated `host:port` list of sidecar addresses (client mode only) | -| `gerrit.trigger.coordination.hazelcast.client.cluster.name` | `gerrit-trigger-cluster` | Cluster name to connect to in client mode | -| `gerrit.trigger.coordination.hazelcast.cluster.name` | `gerrit-trigger-cluster` | Cluster name used when running as an embedded member | -| `gerrit.trigger.coordination.hazelcast.port` | `5702` | Hazelcast member port (member mode only) | -| `gerrit.trigger.coordination.hazelcast.port.count` | `10` | Number of ports to try when auto-incrementing | -| `gerrit.trigger.coordination.hazelcast.discovery.mode` | `auto` | Discovery mechanism: `kubernetes`, `tcp`, or `multicast` (testing only) | -| `gerrit.trigger.coordination.hazelcast.k8s.service.name` | `jenkins` | Kubernetes service name used for peer discovery | -| `gerrit.trigger.coordination.hazelcast.k8s.namespace` | `default` | Kubernetes namespace for peer discovery | -| `gerrit.trigger.coordination.hazelcast.tcp.members` | — | Static member list for TCP discovery, e.g. `replica-0.jenkins:5702,replica-1.jenkins:5702` | -| `gerrit.trigger.coordination.hazelcast.operation.timeout` | `30000` | Distributed operation timeout in milliseconds | - -Port `5702` is used by default to avoid conflicts with CloudBees Core CI's internal -Hazelcast cluster, which occupies port `5701`. - - -## Configuration Examples - -### Kubernetes — Client Mode with Hazelcast Sidecar (Recommended) - -In Kubernetes HA/HS deployments the recommended setup runs a Hazelcast container as a -sidecar in each Jenkins pod. The plugin connects to it as a lightweight client, reusing -the cross-pod cluster the sidecar maintains. - -Add the following JVM arguments to the Jenkins controller: - - -Dgerrit.trigger.coordination.mode=hazelcast - -Dgerrit.trigger.coordination.hazelcast.instance.mode=client - -Dgerrit.trigger.coordination.hazelcast.client.addresses=localhost:5702 - -Dgerrit.trigger.coordination.hazelcast.client.cluster.name=gerrit-trigger-cluster - -Add the sidecar container to the controller pod spec: - -```yaml -- name: hazelcast - image: hazelcast/hazelcast:5.3.8 - ports: - - containerPort: 5702 - name: hazelcast - env: - - name: JAVA_OPTS - value: >- - -Dhazelcast.config=/dev/stdin - -Dhazelcast.local.publicAddress=$(POD_IP):5702 - - name: HZ_CLUSTERNAME - value: gerrit-trigger-cluster - - name: HZ_NETWORK_PORT_PORT - value: "5702" -``` - -Grant the pod's service account read access to Kubernetes endpoints so Hazelcast can -discover its peers: - -```yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: hazelcast-gerrit-trigger -rules: - - apiGroups: [""] - resources: ["endpoints", "pods", "nodes", "services"] - verbs: ["get", "list"] - - apiGroups: ["discovery.k8s.io"] - resources: ["endpointslices"] - verbs: ["get", "list"] -``` - - -### TCP/IP — Static Member List (Non-Kubernetes HA) - -For HA deployments outside Kubernetes, configure a static list of member addresses: - - -Dgerrit.trigger.coordination.mode=hazelcast - -Dgerrit.trigger.coordination.hazelcast.discovery.mode=tcp - -Dgerrit.trigger.coordination.hazelcast.tcp.members=replica-0.jenkins:5702,replica-1.jenkins:5702 +# Distributed storage support + +The plugin supports an distributed storage for the memory that will track the events (for example using Hazelcast client). +See [README_DISTRIBUTED_STORAGE.md](README_DISTRIBUTED_STORAGE.md) for configuration +properties and deployment examples. # License diff --git a/README_DISTRIBUTED_STORAGE.md b/README_DISTRIBUTED_STORAGE.md new file mode 100644 index 000000000..a95279870 --- /dev/null +++ b/README_DISTRIBUTED_STORAGE.md @@ -0,0 +1,86 @@ +# Distributed storage support + +The plugin supports Distributed storage support where two or more Jenkins instance +run in parallel (sharing the gerrit memory of the plugin). When enabled, a Hazelcast +cluster coordinates the instances so that: + +- Each Gerrit event is processed by **exactly one** instance (event claiming) +- Build state is shared across instances (distributed build memory) +- Gerrit feedback (votes and comments) is sent **exactly once** per build event + +By default the plugin runs in **local mode** and requires no additional configuration. +Local mode is fully backward-compatible with single-instance Jenkins deployments. + +Alternative coordination backends can be implemented by extending +[`CoordinationModeProvider`](src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java) +— a Jenkins `ExtensionPoint` that wires together the storage, event-claiming, and +notification-claiming strategies for a given coordination mode. A higher `@Extension` +ordinal takes precedence over the built-in Hazelcast provider. + +## Hazlecast implementation + +Hazelcast mode is activated via a JVM system property. Jenkins connects as a lightweight +client to a Hazelcast sidecar container, reusing the cross-pod cluster the sidecar +maintains. + +### Configuration Properties + +All distributed storage settings are controlled by JVM system properties passed to Jenkins on startup. + +| Property | Default | Description | +|---|---|---| +| `gerrit.trigger.coordination.mode` | `local` | Set to `hazelcast` to enable HA/HS coordination | +| `gerrit.trigger.coordination.hazelcast.client.addresses` | `localhost:5702` | Comma-separated `host:port` list of sidecar addresses | +| `gerrit.trigger.coordination.hazelcast.client.cluster.name` | `gerrit-trigger-cluster` | Cluster name to connect to | + +Port `5702` is used by default to avoid potential conflicts with other Hazelcast cluster, which could occupy port `5701`. + +### Configuration Example + +#### Kubernetes — Client Mode with Hazelcast Sidecar + +In Kubernetes HA/HS deployments the recommended setup runs a Hazelcast container as a +sidecar in each Jenkins pod. The plugin connects to it as a lightweight client, reusing +the cross-pod cluster the sidecar maintains. + +Add the following JVM arguments to the Jenkins instance: + + -Dgerrit.trigger.coordination.mode=hazelcast + -Dgerrit.trigger.coordination.hazelcast.client.addresses=localhost:5702 + -Dgerrit.trigger.coordination.hazelcast.client.cluster.name=gerrit-trigger-cluster + +Add the sidecar container to the instance pod spec: + +```yaml +- name: hazelcast + image: hazelcast/hazelcast:5.3.8 + ports: + - containerPort: 5702 + name: hazelcast + env: + - name: JAVA_OPTS + value: >- + -Dhazelcast.config=/dev/stdin + -Dhazelcast.local.publicAddress=$(POD_IP):5702 + - name: HZ_CLUSTERNAME + value: gerrit-trigger-cluster + - name: HZ_NETWORK_PORT_PORT + value: "5702" +``` + +Grant the pod's service account read access to Kubernetes endpoints so Hazelcast can +discover its peers: + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: hazelcast-gerrit-trigger +rules: + - apiGroups: [""] + resources: ["endpoints", "pods", "nodes", "services"] + verbs: ["get", "list"] + - apiGroups: ["discovery.k8s.io"] + resources: ["endpointslices"] + verbs: ["get", "list"] +``` From 5e89289edd7898ce3293881e2a919ed508827945 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Wed, 1 Jul 2026 12:36:53 +0200 Subject: [PATCH 50/87] Removing Hazelcast member mode --- .../HazelcastBuildMemoryStorage.java | 49 +-- .../hazelcast/HazelcastConfig.java | 305 +----------------- .../hazelcast/HazelcastManager.java | 68 +--- 3 files changed, 50 insertions(+), 372 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 7bd26a752..9c015a0a6 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -147,11 +147,9 @@ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { * Delay in seconds before writing a deferred cross-replica abort inbox entry. *

            * When {@code started()} detects that an entry is already marked {@code isCancelling=true} - * (race condition: build started after the abort decision was made), we write to the abort - * inbox to notify other replicas after this delay so that the CPS engine has had time to - * attach a {@link org.jenkinsci.plugins.workflow.flow.FlowExecution} before the interrupt - * arrives. This value is a safety-net upper bound; {@link #handleAbortRequest} will fire - * earlier once {@link PipelineAbortHelper#isPipelineNotYetStarted} returns {@code false}. + * (race condition: build started after the abort decision was made), the abort inbox entry + * is written after this delay so the CPS engine has had time to attach a + * {@link org.jenkinsci.plugins.workflow.flow.FlowExecution} before the interrupt arrives. */ private static final long DEFERRED_ABORT_DELAY_SECONDS = 3L; @@ -161,6 +159,15 @@ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { */ private static final long ABORT_RETRY_POLL_MS = 250L; + /** + * Maximum number of poll attempts in {@link #handleAbortRequest} before interrupting + * regardless of CPS execution state. + *

            + * Total maximum wait = {@link #ABORT_RETRY_POLL_MS} * {@code ABORT_MAX_RETRIES} + * = 250 ms * 12 = 3 seconds. + */ + private static final int ABORT_MAX_RETRIES = 12; + /** * Maximum time in seconds to wait when acquiring a distributed lock. *

            @@ -246,18 +253,18 @@ private void registerAbortInboxListener() { * @param causeType cause type string ({@value #CAUSE_ABANDONED} or {@value #CAUSE_NEW_PATCHSET}) */ private static void handleAbortRequest(String jobName, String buildId, String causeType) { - handleAbortRequest(jobName, buildId, causeType, System.currentTimeMillis()); + handleAbortRequest(jobName, buildId, causeType, ABORT_MAX_RETRIES); } /** - * @param jobName full name of the Jenkins job - * @param buildId build number as a string - * @param causeType abort cause type ({@link #CAUSE_NEW_PATCHSET} or {@link #CAUSE_ABANDONED}) - * @param firstAttemptMs wall-clock time of the first attempt, used to enforce the - * {@link #DEFERRED_ABORT_DELAY_SECONDS} safety-net upper bound + * @param jobName full name of the Jenkins job + * @param buildId build number as a string + * @param causeType abort cause type ({@link #CAUSE_NEW_PATCHSET} or {@link #CAUSE_ABANDONED}) + * @param retriesLeft remaining poll attempts before interrupting regardless of CPS state; + * decremented on each reschedule until it reaches zero */ private static void handleAbortRequest(String jobName, String buildId, - String causeType, long firstAttemptMs) { + String causeType, int retriesLeft) { try (ACLContext ignored = ACL.as(ACL.SYSTEM)) { Jenkins jenkins = Jenkins.getInstanceOrNull(); if (jenkins == null) { @@ -276,8 +283,8 @@ private static void handleAbortRequest(String jobName, String buildId, // FlowExecution.getCurrentHeads() is non-empty) before delivering the interrupt. // Interrupting during CPS initialisation has no effect — the interrupt flag is // set before any step is registered, so it is silently lost. - // We poll every ABORT_RETRY_POLL_MS; the safety-net cap of DEFERRED_ABORT_DELAY_SECONDS - // ensures we never wait longer than the previous time-based approach. + // We poll every ABORT_RETRY_POLL_MS for up to ABORT_MAX_RETRIES attempts + // (ABORT_RETRY_POLL_MS * ABORT_MAX_RETRIES = 3 s total maximum wait). boolean notYetStarted; try { notYetStarted = PipelineAbortHelper.isPipelineNotYetStarted(build); @@ -286,18 +293,16 @@ private static void handleAbortRequest(String jobName, String buildId, notYetStarted = false; } if (notYetStarted) { - long elapsedMs = System.currentTimeMillis() - firstAttemptMs; - long maxWaitMs = TimeUnit.SECONDS.toMillis(DEFERRED_ABORT_DELAY_SECONDS); - if (elapsedMs < maxWaitMs) { - logger.debug("Abort-inbox: build={}/{} CPS not yet started, retrying in {}ms", - jobName, buildId, ABORT_RETRY_POLL_MS); + if (retriesLeft > 0) { + logger.debug("Abort-inbox: build={}/{} CPS not yet started, retrying in {}ms ({} attempts left)", + jobName, buildId, ABORT_RETRY_POLL_MS, retriesLeft); Timer.get().schedule( - () -> handleAbortRequest(jobName, buildId, causeType, firstAttemptMs), + () -> handleAbortRequest(jobName, buildId, causeType, retriesLeft - 1), ABORT_RETRY_POLL_MS, TimeUnit.MILLISECONDS); return; } - logger.info("Abort-inbox: build={}/{} CPS still not started after {}ms, interrupting anyway", - jobName, buildId, elapsedMs); + logger.info("Abort-inbox: build={}/{} CPS still not started after {} attempts, interrupting anyway", + jobName, buildId, ABORT_MAX_RETRIES); } CauseOfInterruption cause; diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java index 53803ef91..c0c979490 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastConfig.java @@ -24,9 +24,6 @@ package com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; import com.hazelcast.client.config.ClientConfig; -import com.hazelcast.config.Config; -import com.hazelcast.config.JoinConfig; -import com.hazelcast.config.NetworkConfig; import jenkins.model.Jenkins; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -46,79 +43,7 @@ public final class HazelcastConfig { public static final String DEFAULT_CLUSTER_NAME = "gerrit-trigger-cluster"; /** - * Default Hazelcast port. - */ - public static final int DEFAULT_PORT = 5702; - - /** - * Default number of ports to try for auto-increment. - */ - public static final int DEFAULT_PORT_COUNT = 10; - - /** - * Default operation call timeout in milliseconds. - */ - public static final String DEFAULT_OPERATION_TIMEOUT = "30000"; - - /** - * System property to specify cluster name. - * Default: "gerrit-trigger-cluster" - */ - public static final String CLUSTER_NAME_PROPERTY = "gerrit.trigger.coordination.hazelcast.cluster.name"; - - /** - * System property to specify Hazelcast port. - * Default: 5702 - */ - public static final String PORT_PROPERTY = "gerrit.trigger.coordination.hazelcast.port"; - - /** - * System property to specify number of ports to try for auto-increment. - * Default: 10 - */ - public static final String PORT_COUNT_PROPERTY = "gerrit.trigger.coordination.hazelcast.port.count"; - - /** - * System property to specify operation call timeout in milliseconds. - * Default: 30000 (30 seconds) - */ - public static final String OPERATION_TIMEOUT_PROPERTY = "gerrit.trigger.coordination.hazelcast.operation.timeout"; - - /** - * System property to specify discovery mode. - * Values: "kubernetes", "tcp", "multicast" (for testing only). - */ - public static final String DISCOVERY_MODE_PROPERTY = "gerrit.trigger.coordination.hazelcast.discovery.mode"; - - /** - * System property to specify Kubernetes service name. - * Default: "jenkins" - */ - public static final String K8S_SERVICE_NAME_PROPERTY = "gerrit.trigger.coordination.hazelcast.k8s.service.name"; - - /** - * System property to specify Kubernetes namespace. - * Default: "default" - */ - public static final String K8S_NAMESPACE_PROPERTY = "gerrit.trigger.coordination.hazelcast.k8s.namespace"; - - /** - * System property to specify TCP/IP members (comma-separated). - * Example: "replica-0.jenkins:5701,replica-1.jenkins:5701" - */ - public static final String TCP_MEMBERS_PROPERTY = "gerrit.trigger.coordination.hazelcast.tcp.members"; - - /** - * System property to set Hazelcast instance mode. - * Values: "member" (default, creates an embedded cluster member) or - * "client" (connects to an existing Hazelcast cluster, e.g. a sidecar container). - * Client mode is recommended when a Hazelcast sidecar is already present in the pod, - * as it reuses the existing cross-pod cluster instead of creating a new one. - */ - public static final String INSTANCE_MODE_PROPERTY = "gerrit.trigger.coordination.hazelcast.instance.mode"; - - /** - * System property to specify addresses for Hazelcast client mode (comma-separated host:port). + * System property to specify addresses for Hazelcast client (comma-separated host:port). * Default: "localhost:5702" — assumes a Hazelcast sidecar listening on port 5702 in the same pod. * Example: "localhost:5702" */ @@ -126,7 +51,7 @@ public final class HazelcastConfig { "gerrit.trigger.coordination.hazelcast.client.addresses"; /** - * System property to specify the cluster name for Hazelcast client mode. + * System property to specify the cluster name to connect to. * Must match the cluster name of the target Hazelcast cluster. * Default: {@link #DEFAULT_CLUSTER_NAME}. */ @@ -134,7 +59,7 @@ public final class HazelcastConfig { "gerrit.trigger.coordination.hazelcast.client.cluster.name"; /** - * Default address for Hazelcast client mode: local sidecar on port 5702. + * Default address for Hazelcast client: local sidecar on port 5702. */ public static final String DEFAULT_CLIENT_ADDRESS = "localhost:5702"; @@ -145,208 +70,12 @@ private HazelcastConfig() { // Utility class } - /** - * Creates a Hazelcast configuration suitable for the current environment. - * - * @return configured Hazelcast Config object - */ - public static Config createConfig() { - Config config = new Config(); - - // Set cluster name (configurable via system property) - String clusterName = System.getProperty(CLUSTER_NAME_PROPERTY, DEFAULT_CLUSTER_NAME); - config.setClusterName(clusterName); - logger.info("Hazelcast cluster name: {}", clusterName); - - // Set instance name (includes Jenkins URL for identification) - String instanceName = generateInstanceName(); - config.setInstanceName(instanceName); - logger.info("Hazelcast instance name: {}", instanceName); - - // Configure network and discovery - configureNetwork(config); - - // Configure settings (all configurable via system properties) - config.setProperty("hazelcast.logging.type", "slf4j"); - config.setProperty("hazelcast.shutdownhook.enabled", "false"); // We manage shutdown - - String operationTimeout = System.getProperty(OPERATION_TIMEOUT_PROPERTY, DEFAULT_OPERATION_TIMEOUT); - config.setProperty("hazelcast.operation.call.timeout.millis", operationTimeout); - logger.info("Hazelcast operation timeout: {} ms", operationTimeout); - - // Register Compact Serializers for event claiming and build memory - // This enables cross-JVM serialization compatibility with sidecar deployment - config.getSerializationConfig() - .getCompactSerializationConfig() - .addSerializer(new EventClaimSerializer()) - .addSerializer(new EntryDataSerializer()) - .addSerializer(new MemoryImprintDataSerializer()); - logger.debug("Registered Compact Serializers for EventClaim, EntryData, and MemoryImprintData"); - - logger.info("Hazelcast configuration created for cluster: {}", clusterName); - - return config; - } - - /** - * Configures network settings and discovery mechanism. - * - * @param config the Hazelcast config to configure - */ - private static void configureNetwork(Config config) { - NetworkConfig networkConfig = config.getNetworkConfig(); - - // Set port (configurable via system property) - int port = Integer.parseInt(System.getProperty(PORT_PROPERTY, String.valueOf(DEFAULT_PORT))); - networkConfig.setPort(port); - networkConfig.setPortAutoIncrement(true); - - // Set port count (configurable via system property) - int portCount = Integer.parseInt(System.getProperty(PORT_COUNT_PROPERTY, String.valueOf(DEFAULT_PORT_COUNT))); - networkConfig.setPortCount(portCount); - - logger.info("Hazelcast network: port={}, portCount={} (will try ports {}-{})", - port, portCount, port, port + portCount - 1); - - JoinConfig joinConfig = networkConfig.getJoin(); - - // Determine discovery mode - String discoveryMode = System.getProperty(DISCOVERY_MODE_PROPERTY, "auto"); - logger.info("Hazelcast discovery mode: {}", discoveryMode); - - if ("multicast".equalsIgnoreCase(discoveryMode)) { - // Multicast mode - primarily for testing - configureMulticastDiscovery(joinConfig); - } else if ("kubernetes".equalsIgnoreCase(discoveryMode)) { - // Explicit kubernetes mode - always use Kubernetes discovery regardless of environment - configureKubernetesDiscovery(joinConfig, port); - } else if ("tcp".equalsIgnoreCase(discoveryMode) || hasTcpMembersConfigured()) { - // Explicit tcp mode, or TCP members configured - always use TCP discovery - configureTcpDiscovery(joinConfig, port); - } else { - // Auto-detect: no explicit mode set, try Kubernetes first, then TCP - logger.info("Auto-detecting discovery mechanism..."); - if (isKubernetesEnvironment()) { - configureKubernetesDiscovery(joinConfig, port); - } else { - configureTcpDiscovery(joinConfig, port); - } - } - - // Disable multicast unless explicitly enabled - if (!"multicast".equalsIgnoreCase(discoveryMode)) { - joinConfig.getMulticastConfig().setEnabled(false); - } - } - - /** - * Configures Kubernetes discovery. - * - * @param joinConfig the join configuration - * @param port the Hazelcast port to discover (filters out other Hazelcast instances on different ports) - */ - private static void configureKubernetesDiscovery(JoinConfig joinConfig, int port) { - String serviceName = System.getProperty(K8S_SERVICE_NAME_PROPERTY, "jenkins"); - String namespace = System.getProperty(K8S_NAMESPACE_PROPERTY, "default"); - - logger.info("Configuring Kubernetes discovery: service={}, namespace={}, port={}", - serviceName, namespace, port); - - joinConfig.getKubernetesConfig() - .setEnabled(true) - .setProperty("service-name", serviceName) - .setProperty("namespace", namespace) - .setProperty("service-port", String.valueOf(port)); - - // Disable other discovery methods - joinConfig.getTcpIpConfig().setEnabled(false); - joinConfig.getAwsConfig().setEnabled(false); - joinConfig.getAzureConfig().setEnabled(false); - } - - /** - * Configures TCP/IP discovery with static member list. - * - * @param joinConfig the join configuration - * @param port the Hazelcast port (used in fallback and example messages) - */ - private static void configureTcpDiscovery(JoinConfig joinConfig, int port) { - String tcpMembers = System.getProperty(TCP_MEMBERS_PROPERTY, ""); - - if (tcpMembers.isEmpty()) { - logger.warn("TCP discovery mode selected but no members configured. " - + "Set {} system property.", TCP_MEMBERS_PROPERTY); - logger.warn("Example: -D{}=replica-0.jenkins:{},replica-1.jenkins:{}", - TCP_MEMBERS_PROPERTY, port, port); - // Use localhost as fallback for single-instance testing - tcpMembers = "localhost:" + port; - } - - logger.info("Configuring TCP/IP discovery with members: {}", tcpMembers); - - // Split comma-separated member list and add each member individually - String[] members = tcpMembers.split(","); - com.hazelcast.config.TcpIpConfig tcpIpConfig = joinConfig.getTcpIpConfig(); - tcpIpConfig.setEnabled(true); - - for (String member : members) { - String trimmedMember = member.trim(); - if (!trimmedMember.isEmpty()) { - tcpIpConfig.addMember(trimmedMember); - logger.debug("Added TCP member: {}", trimmedMember); - } - } - - // Disable other discovery methods - joinConfig.getKubernetesConfig().setEnabled(false); - joinConfig.getAwsConfig().setEnabled(false); - joinConfig.getAzureConfig().setEnabled(false); - } - - /** - * Configures multicast discovery. - *

            - * Warning: Multicast is NOT suitable for production use. - * This mode is primarily for testing purposes where a simple discovery - * mechanism is needed without Kubernetes or TCP configuration. - *

            - * Multicast allows Hazelcast instances on the same network segment to - * automatically discover each other. - * - * @param joinConfig the join configuration - */ - private static void configureMulticastDiscovery(JoinConfig joinConfig) { - logger.warn("Configuring multicast discovery - NOT SUITABLE FOR PRODUCTION, TESTING ONLY"); - - joinConfig.getMulticastConfig() - .setEnabled(true); - - // Disable other discovery methods - joinConfig.getTcpIpConfig().setEnabled(false); - joinConfig.getKubernetesConfig().setEnabled(false); - joinConfig.getAwsConfig().setEnabled(false); - joinConfig.getAzureConfig().setEnabled(false); - } - - /** - * Returns true if Hazelcast client mode is configured. - *

            - * In client mode the plugin connects to an existing Hazelcast cluster (e.g. a sidecar) - * instead of creating its own embedded member. This avoids port conflicts and reuses - * the cross-pod cluster that the sidecar already maintains. - * - * @return true when {@link #INSTANCE_MODE_PROPERTY} is set to "client" - */ - public static boolean isClientMode() { - return "client".equalsIgnoreCase(System.getProperty(INSTANCE_MODE_PROPERTY, "member")); - } - /** * Creates a Hazelcast client configuration to connect to an existing cluster. *

            - * Used when {@link #isClientMode()} is true. The client connects to the addresses - * specified by {@link #CLIENT_ADDRESSES_PROPERTY} (default: {@link #DEFAULT_CLIENT_ADDRESS}) - * and joins the cluster whose name matches {@link #CLIENT_CLUSTER_NAME_PROPERTY}. + * The client connects to the addresses specified by {@link #CLIENT_ADDRESSES_PROPERTY} + * (default: {@link #DEFAULT_CLIENT_ADDRESS}) and joins the cluster whose name matches + * {@link #CLIENT_CLUSTER_NAME_PROPERTY}. * * @return configured Hazelcast ClientConfig */ @@ -382,27 +111,7 @@ public static ClientConfig createClientConfig() { } /** - * Checks if running in Kubernetes environment. - * - * @return true if Kubernetes environment detected - */ - private static boolean isKubernetesEnvironment() { - // Check for Kubernetes service account token - return System.getenv("KUBERNETES_SERVICE_HOST") != null; - } - - /** - * Checks if TCP members are configured via system property. - * - * @return true if TCP members property is set - */ - private static boolean hasTcpMembersConfigured() { - String tcpMembers = System.getProperty(TCP_MEMBERS_PROPERTY); - return tcpMembers != null && !tcpMembers.trim().isEmpty(); - } - - /** - * Generates a unique instance name for this Hazelcast member. + * Generates a unique instance name for this Hazelcast client. * Includes Jenkins URL and hostname for identification. * * @return instance name diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java index 24be5bb34..1f5a1de55 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastManager.java @@ -24,13 +24,12 @@ package com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; import com.hazelcast.client.HazelcastClient; -import com.hazelcast.core.Hazelcast; import com.hazelcast.core.HazelcastInstance; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Manages the lifecycle of Hazelcast embedded member. + * Manages the lifecycle of the Hazelcast client. *

            * This manager handles initialization and shutdown of the Hazelcast instance. * Whether to initialize is determined by {@link HazelcastCoordinationProvider#isAvailable()}, @@ -54,16 +53,15 @@ private HazelcastManager() { } /** - * Initializes Hazelcast in the mode determined by {@link HazelcastConfig#isClientMode()}. + * Initializes the Hazelcast client. *

            - * In member mode (default) an embedded Hazelcast member is created that forms - * its own cluster with other replicas. In client mode a lightweight Hazelcast - * client connects to an existing cluster (e.g. a sidecar container on the same pod), - * reusing its cross-pod topology without starting a new member. + * The client connects to an existing cluster (e.g. a Hazelcast sidecar on the same pod) + * and accesses its distributed maps. No cluster member is created inside Jenkins, so there + * is no port conflict with the sidecar and no need for cross-pod member discovery. *

            * This method is idempotent — calling it multiple times returns the existing instance. * - * @return the Hazelcast instance (member or client) + * @return the Hazelcast client instance * @throws RuntimeException if initialization fails */ public static HazelcastInstance initialize() { @@ -77,13 +75,7 @@ public static HazelcastInstance initialize() { } try { - HazelcastInstance hazelcastInstance; - if (HazelcastConfig.isClientMode()) { - hazelcastInstance = initializeClient(); - } else { - hazelcastInstance = initializeMember(); - } - + HazelcastInstance hazelcastInstance = initializeClient(); HazelcastInstanceProvider.setInstance(hazelcastInstance); initialized = true; return hazelcastInstance; @@ -96,26 +88,8 @@ public static HazelcastInstance initialize() { } } - /** - * Creates a Hazelcast embedded member using {@link HazelcastConfig#createConfig()}. - * - * @return the initialized Hazelcast member instance - */ - private static HazelcastInstance initializeMember() { - logger.info("Initializing Hazelcast embedded member..."); - com.hazelcast.config.Config config = HazelcastConfig.createConfig(); - HazelcastInstance hz = Hazelcast.newHazelcastInstance(config); - logger.info("Hazelcast embedded member initialized. Cluster: {}, Instance: {}, Members: {}", - config.getClusterName(), hz.getName(), hz.getCluster().getMembers().size()); - return hz; - } - /** * Creates a Hazelcast client using {@link HazelcastConfig#createClientConfig()}. - *

            - * The client connects to an existing cluster (e.g. a Hazelcast sidecar on the same pod) - * and accesses its distributed maps. No new cluster member is created, so there is no - * port conflict with the sidecar and no need for cross-pod member discovery. * * @return the initialized Hazelcast client instance */ @@ -129,9 +103,7 @@ private static HazelcastInstance initializeClient() { } /** - * Shuts down Hazelcast gracefully. - *

            - * Shuts down the Hazelcast embedded member and cleans up resources. + * Shuts down the Hazelcast client gracefully. *

            * This method is idempotent - calling it multiple times has no effect if already shut down. */ @@ -143,7 +115,7 @@ public static void shutdown() { } try { - logger.info("Shutting down Hazelcast embedded member..."); + logger.info("Shutting down Hazelcast client..."); HazelcastInstance instance = HazelcastInstanceProvider.getInstance(); if (instance != null) { @@ -152,7 +124,7 @@ public static void shutdown() { // Shutdown the instance instance.shutdown(); - logger.info("Hazelcast embedded member shut down: {}", instanceName); + logger.info("Hazelcast client shut down: {}", instanceName); } // Clear the provider @@ -222,20 +194,12 @@ public static String getStatus() { try { int clusterSize = instance.getCluster().getMembers().size(); - String instanceName = instance.getName(); - - if (HazelcastConfig.isClientMode()) { - // getConfig() is not supported on Hazelcast clients - String clusterName = System.getProperty( - HazelcastConfig.CLIENT_CLUSTER_NAME_PROPERTY, - HazelcastConfig.DEFAULT_CLUSTER_NAME); - return String.format("Hazelcast Client: Running | Cluster: %s | Members: %d", - clusterName, clusterSize); - } - - String clusterName = instance.getConfig().getClusterName(); - return String.format("Hazelcast: Running | Cluster: %s | Instance: %s | Members: %d", - clusterName, instanceName, clusterSize); + // getConfig() is not supported on Hazelcast clients — read the property directly + String clusterName = System.getProperty( + HazelcastConfig.CLIENT_CLUSTER_NAME_PROPERTY, + HazelcastConfig.DEFAULT_CLUSTER_NAME); + return String.format("Hazelcast Client: Running | Cluster: %s | Members: %d", + clusterName, clusterSize); } catch (Exception e) { return String.format("Hazelcast: Error getting status: %s", e.getMessage()); } From 49938e12a1cf75121efc2ab81ed4267fcab7d183 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Thu, 2 Jul 2026 15:06:18 +0200 Subject: [PATCH 51/87] Changing README files for better nomenclature --- README.md | 6 +++--- .../README_DISTRIBUTED_EVENT_MANAGEMENT.md | 15 ++++++++------- 2 files changed, 11 insertions(+), 10 deletions(-) rename README_DISTRIBUTED_STORAGE.md => docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md (80%) diff --git a/README.md b/README.md index ceab77862..5a8f14fa6 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,10 @@ Run checkstyle mvn checkstyle:checkstyle -# Distributed storage support +# Distributed Event Management support -The plugin supports an distributed storage for the memory that will track the events (for example using Hazelcast client). -See [README_DISTRIBUTED_STORAGE.md](README_DISTRIBUTED_STORAGE.md) for configuration +The plugin supports an distributed event management for the memory that will track the events (for example using Hazelcast client). +See [README_DISTRIBUTED_EVENT_MANAGEMENT.md](docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md) for configuration properties and deployment examples. diff --git a/README_DISTRIBUTED_STORAGE.md b/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md similarity index 80% rename from README_DISTRIBUTED_STORAGE.md rename to docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md index a95279870..0ce28996b 100644 --- a/README_DISTRIBUTED_STORAGE.md +++ b/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md @@ -1,6 +1,6 @@ -# Distributed storage support +# Distributed Event Management support -The plugin supports Distributed storage support where two or more Jenkins instance +The plugin supports Distributed Event Management support where two or more Jenkins instance run in parallel (sharing the gerrit memory of the plugin). When enabled, a Hazelcast cluster coordinates the instances so that: @@ -8,11 +8,11 @@ cluster coordinates the instances so that: - Build state is shared across instances (distributed build memory) - Gerrit feedback (votes and comments) is sent **exactly once** per build event -By default the plugin runs in **local mode** and requires no additional configuration. +By default, the plugin runs in **local mode** and requires no additional configuration. Local mode is fully backward-compatible with single-instance Jenkins deployments. Alternative coordination backends can be implemented by extending -[`CoordinationModeProvider`](src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java) +[`CoordinationModeProvider`](../src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java) — a Jenkins `ExtensionPoint` that wires together the storage, event-claiming, and notification-claiming strategies for a given coordination mode. A higher `@Extension` ordinal takes precedence over the built-in Hazelcast provider. @@ -39,9 +39,10 @@ Port `5702` is used by default to avoid potential conflicts with other Hazelcast #### Kubernetes — Client Mode with Hazelcast Sidecar -In Kubernetes HA/HS deployments the recommended setup runs a Hazelcast container as a -sidecar in each Jenkins pod. The plugin connects to it as a lightweight client, reusing -the cross-pod cluster the sidecar maintains. +The plugin can connect to Hazelcast cluster as a lightweight client. For example, if we are running +K8s environment with the Jenkins instance inside a pod, we can have a side-container with Hazelcast +to set up the Hazelcast cluster. In this kind of cases, we would the a configuration setup similar +to the following one: Add the following JVM arguments to the Jenkins instance: From b130edaf904920bf2e582e235136821e3eafb934 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Thu, 2 Jul 2026 15:27:32 +0200 Subject: [PATCH 52/87] Changing nomenclature --- docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md | 8 ++++---- .../hudson/plugins/gerrit/trigger/PluginImpl.java | 4 ++-- .../trigger/coordination/CoordinationModeFactory.java | 2 +- .../coordination/LocalCoordinationProvider.java | 2 +- .../trigger/coordination/hazelcast/EventClaim.java | 2 +- .../coordination/hazelcast/EventIdentifier.java | 2 +- .../hazelcast/HazelcastBuildMemoryStorage.java | 2 +- .../hazelcast/HazelcastCoordinationProvider.java | 4 ++-- .../hazelcast/HazelcastEventClaimStrategy.java | 4 ++-- .../hazelcast/HazelcastNotificationClaimStrategy.java | 4 ++-- .../hazelcast/HazelcastQueueCancellationStrategy.java | 10 +++++----- .../trigger/gerritnotifier/GerritNotifierFactory.java | 4 ++-- .../gerritnotifier/LocalQueueCancellationStrategy.java | 2 +- .../gerrit/trigger/hudsontrigger/EventListener.java | 6 +++--- .../gerrit/trigger/spi/CoordinationModeProvider.java | 4 ++-- .../plugins/gerrit/trigger/spi/EventClaimStrategy.java | 2 +- .../gerrit/trigger/spi/NotificationClaimStrategy.java | 2 +- .../gerrit/trigger/spi/QueueCancellationStrategy.java | 6 +++--- .../BuildCancellationHazelcastIntegrationTest.java | 2 +- 19 files changed, 36 insertions(+), 36 deletions(-) diff --git a/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md b/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md index 0ce28996b..7e5fe90ef 100644 --- a/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md +++ b/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md @@ -27,11 +27,11 @@ maintains. All distributed storage settings are controlled by JVM system properties passed to Jenkins on startup. -| Property | Default | Description | -|---|---|---| -| `gerrit.trigger.coordination.mode` | `local` | Set to `hazelcast` to enable HA/HS coordination | +| Property | Default | Description | +|---|---|-------------------------------------------------------| +| `gerrit.trigger.coordination.mode` | `local` | Set to `hazelcast` to enable distributed coordination | | `gerrit.trigger.coordination.hazelcast.client.addresses` | `localhost:5702` | Comma-separated `host:port` list of sidecar addresses | -| `gerrit.trigger.coordination.hazelcast.client.cluster.name` | `gerrit-trigger-cluster` | Cluster name to connect to | +| `gerrit.trigger.coordination.hazelcast.client.cluster.name` | `gerrit-trigger-cluster` | Cluster name to connect to | Port `5702` is used by default to avoid potential conflicts with other Hazelcast cluster, which could occupy port `5701`. diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java index 9f93b0314..380125384 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java @@ -147,7 +147,7 @@ public class PluginImpl extends GlobalConfiguration { /** * System property: minimum number of Hazelcast cluster members expected before connecting to Gerrit. * Default 1 disables the wait (single-instance or local mode). - * Set to 2 or more in HA/HS deployments to prevent the startup race where events arrive before + * Set to 2 or more in distributed installations to prevent the startup race where events arrive before * the distributed claim map is shared across replicas. */ public static final String HAZELCAST_EXPECTED_MEMBERS_PROPERTY = @@ -638,7 +638,7 @@ public void start() { * Waits for the Hazelcast cluster to reach the expected number of members before * Gerrit server connections are opened. *

            - * In HA/HS deployments each replica has its own SSH connection to Gerrit and therefore + * In distributed scenarios each replica has its own SSH connection to Gerrit and therefore * receives every event independently. Without this guard, a replica that starts while * the Hazelcast cluster is still forming will process events against its own single-member * IMap, making the distributed claim invisible to other replicas and causing duplicate builds. diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/CoordinationModeFactory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/CoordinationModeFactory.java index b13cd784b..bf34362a0 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/CoordinationModeFactory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/CoordinationModeFactory.java @@ -206,7 +206,7 @@ public NotificationClaimStrategy getClaimStrategy() { * The mode is discovered and instances are created on first access.

            * *

            The EventClaimStrategy prevents duplicate build processing when multiple Jenkins - * instances receive the same Gerrit event in HA/HS deployments.

            + * instances receive the same Gerrit event in distributed scenarios.

            * * @return the event claim strategy implementation * @throws IllegalStateException if no available mode provider is found diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java index 00e34c79c..2a4a9d94d 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/LocalCoordinationProvider.java @@ -117,7 +117,7 @@ public EventClaimStrategy createEventClaimStrategy() { /** * Creates a new local queue cancellation strategy instance. - * Always returns false - no HA load balancer present in standalone mode. + * Always returns false - no distributed load balancer present in standalone mode. * * @return a new LocalQueueCancellationStrategy */ diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaim.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaim.java index 03bde1bfd..1a1ce1623 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaim.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaim.java @@ -26,7 +26,7 @@ /** * Represents a claimed Gerrit event in the distributed cluster. *

            - * In HA/HS environments with multiple replicas, each Gerrit event + * In distributed environments with multiple replicas, each Gerrit event * arrives at all replicas. To prevent duplicate builds, replicas use event claiming: * the first replica to claim an event processes it, while others skip it. *

            diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java index a44fea89f..657fb3d76 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java @@ -32,7 +32,7 @@ /** * Utility class for generating unique, consistent event identifiers. *

            - * Event IDs are used for distributed event claiming in HA/HS (High Availability/High Scalability) environments. + * Event IDs are used for distributed event claiming in distributed scenarios. * The same Gerrit event arriving at different replicas must produce the same event ID * to enable proper claim coordination. *

            diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 9c015a0a6..31dad509d 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -61,7 +61,7 @@ import java.util.concurrent.TimeUnit; /** - * Hazelcast-backed implementation of BuildMemoryStorage for HA/HS deployments. + * Hazelcast-backed implementation of BuildMemoryStorage for distributed scenarios. *

            * Uses distributed IMap for storing build memory across multiple Jenkins replicas. * All operations use distributed locks ({@code map.tryLock(key, timeout, unit)}) to prevent race conditions. diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java index 544c9c7de..01dced9d7 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java @@ -172,7 +172,7 @@ public NotificationClaimStrategy createClaimStrategy() { *

            * Uses distributed IMap with atomic {@code putIfAbsent} to ensure only one replica * processes each Gerrit event. The first replica to claim an event processes it, - * while other replicas skip it. This prevents duplicate builds in HA/HS deployments. + * while other replicas skip it. This prevents duplicate builds in distributed scenarios. *

            * Replica-level claiming: Once a replica claims an event, ALL jobs * on that replica can process it. This allows multiple jobs on the same replica to be @@ -191,7 +191,7 @@ public EventClaimStrategy createEventClaimStrategy() { /** * Creates Hazelcast queue cancellation strategy. *

            - * Detects cancellations triggered by the CloudBees HA load balancer so that + * Detects cancellations triggered by the potential distributed load balancer so that * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.GerritQueueListener} * can skip them and avoid sending premature Gerrit feedback. * diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java index 2ad63274d..568896f8a 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java @@ -35,9 +35,9 @@ import java.util.concurrent.TimeUnit; /** - * Hazelcast-backed implementation of EventClaimStrategy for HA/HS deployments. + * Hazelcast-backed implementation of EventClaimStrategy for distributed scenarios. *

            - * In HA/HS (High Availability/High Scalability) environments with multiple replicas, each Gerrit event + * In distributed scenarios with multiple replicas, each Gerrit event * arrives at all replicas via SSH event stream. To prevent duplicate builds, * replicas use distributed event claiming: *

              diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java index 7b69e164f..cb1dd8d19 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java @@ -34,9 +34,9 @@ import java.util.concurrent.TimeUnit; /** - * Hazelcast-backed implementation of NotificationClaimStrategy for HA/HS deployments. + * Hazelcast-backed implementation of NotificationClaimStrategy for distributed scenarios. *

              - * In HA/HS (High Availability/High Scalability) environments with multiple replicas, each replica tracks build + * In distributed scenarios with multiple replicas, each replica tracks build * completions independently. To prevent duplicate notifications to Gerrit, * replicas use distributed notification claiming: *

                diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastQueueCancellationStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastQueueCancellationStrategy.java index e990a532c..0072e4ea2 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastQueueCancellationStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastQueueCancellationStrategy.java @@ -32,13 +32,13 @@ /** * Hazelcast (distributed) implementation of QueueCancellationStrategy. * - *

                Detects queue item cancellations triggered by the CloudBees HA load balancer + *

                Detects queue item cancellations triggered by the potential distributed load balancer * (QueueLoadBalancer), which moves queue items between replicas by cancelling the * original and re-queuing it on the target replica. These cancellations must be * ignored to avoid sending premature "build cancelled" feedback to Gerrit.

                * *

                Class names are checked via string matching to avoid a mandatory compile-time - * dependency on the CloudBees replication plugin.

                + * dependency on any other kind of replication plugin.

                * * @see com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastCoordinationProvider * @see QueueCancellationStrategy @@ -48,7 +48,7 @@ public class HazelcastQueueCancellationStrategy extends QueueCancellationStrateg private static final Logger logger = Logger.getLogger(HazelcastQueueCancellationStrategy.class.getName()); /** - * Returns true if the cancelled item was moved by the HA load balancer. + * Returns true if the cancelled item was moved by the distributed load balancer. * *

                Two markers are checked (either is sufficient):

                *
                  @@ -59,7 +59,7 @@ public class HazelcastQueueCancellationStrategy extends QueueCancellationStrateg *
                * * @param item the queue item that left the queue as cancelled - * @return true if cancelled by the HA load balancer + * @return true if cancelled by the distributed load balancer */ @Override public boolean isLoadBalancedCancellation(@NonNull LeftItem item) { @@ -69,7 +69,7 @@ public boolean isLoadBalancedCancellation(@NonNull LeftItem item) { && item.getCauseOfBlockage().getClass().getName() .contains("LoadBalancedCauseOfBlockage")); if (result) { - logger.fine("Queue item cancelled due to HA load balancing, skipping Gerrit cancellation: " + item); + logger.fine("Queue item cancelled due to distributed load balancing, skipping Gerrit cancellation: " + item); } return result; } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java index 0ec472914..7de3e5a7b 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/GerritNotifierFactory.java @@ -125,7 +125,7 @@ public void queueBuildCompleted(BuildMemory.MemoryImprint memoryImprint, TaskLis if (config != null) { GerritTriggeredEvent event = memoryImprint.getEvent(); - // Claim notification for sending (prevents duplicate notifications in HA/HS environments) + // Claim notification for sending (prevents duplicate notifications in distributed scenarios) NotificationClaimStrategy notificationClaimStrategy = CoordinationModeFactory.get().getClaimStrategy(); notificationClaimStrategy.withClaim(event, "build-completed", () -> { @@ -206,7 +206,7 @@ public void queueBuildStarted(Run build, TaskListener listener, if (serverName != null) { IGerritHudsonTriggerConfig config = getConfig(serverName); if (config != null) { - // Claim notification for sending (prevents duplicate notifications in HA/HS environments) + // Claim notification for sending (prevents duplicate notifications in distributed scenarios) // Build-started uses per-job claim (each job sends its own notification) String jobName = build.getParent().getFullName(); NotificationClaimStrategy notificationClaimStrategy = diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalQueueCancellationStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalQueueCancellationStrategy.java index d138e1da3..38a853f05 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalQueueCancellationStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/LocalQueueCancellationStrategy.java @@ -29,7 +29,7 @@ /** * Local (standalone) implementation of QueueCancellationStrategy. - * Always returns false since there is no HA load balancer in single-instance mode. + * Always returns false since there is no distributed load balancer in single-instance mode. * * @see com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.LocalCoordinationProvider * @see QueueCancellationStrategy diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/EventListener.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/EventListener.java index f8a001e34..13c22bdb2 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/EventListener.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/EventListener.java @@ -127,7 +127,7 @@ public void gerritEvent(GerritEvent event) { if (event instanceof GerritTriggeredEvent) { GerritTriggeredEvent triggeredEvent = (GerritTriggeredEvent)event; - // Claim event for processing (prevents duplicate builds in HA/HS environments) + // Claim event for processing (prevents duplicate builds in distributed scenarios) EventClaimStrategy eventClaimStrategy = CoordinationModeFactory.get().getEventClaimStrategy(); eventClaimStrategy.withClaim(triggeredEvent, () -> { synchronized (EventListener.this) { @@ -172,7 +172,7 @@ public void gerritEvent(ManualPatchsetCreated event) { return; } - // Claim event for processing (prevents duplicate builds in HA/HS environments) + // Claim event for processing (prevents duplicate builds in distributed scenarios) EventClaimStrategy eventClaimStrategy = CoordinationModeFactory.get().getEventClaimStrategy(); eventClaimStrategy.withClaim(event, () -> { synchronized (EventListener.this) { @@ -223,7 +223,7 @@ public void gerritEvent(CommentAdded event) { return; } - // Claim event for processing (prevents duplicate builds in HA/HS environments) + // Claim event for processing (prevents duplicate builds in distributed scenarios) EventClaimStrategy eventClaimStrategy = CoordinationModeFactory.get().getEventClaimStrategy(); eventClaimStrategy.withClaim(event, () -> { synchronized (EventListener.this) { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java index 21d320270..e69b59a99 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/CoordinationModeProvider.java @@ -151,7 +151,7 @@ public static String getConfiguredMode() { * as the highest-priority available provider.

                * *

                The EventClaimStrategy prevents duplicate build processing when multiple Jenkins - * instances receive the same Gerrit event in HA/HS deployments. In local mode, this + * instances receive the same Gerrit event in distributed scenarios. In local mode, this * is a NO-OP (always claims). In distributed mode (e.g., Hazelcast), this uses * distributed coordination to ensure only one instance processes each event.

                * @@ -170,7 +170,7 @@ public static String getConfiguredMode() { * as the highest-priority available provider.

                * *

                The QueueCancellationStrategy determines whether a cancelled Jenkins queue item - * should be ignored because it was moved by the HA load balancer rather than being + * should be ignored because it was moved by the distributed load balancer rather than being * cancelled by a user or a new patchset event. In local mode, this is a NO-OP (always * returns false). In distributed mode (e.g., Hazelcast), this inspects the item for * load-balancer markers.

                diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java index 8b7e38f7b..256dcabad 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/EventClaimStrategy.java @@ -29,7 +29,7 @@ /** * Abstract base class for event claiming strategies in different deployment modes. * Prevents duplicate build processing when multiple Jenkins instances receive - * the same Gerrit event in HA/HS deployments. + * the same Gerrit event in distributed scenarios. * *

                This abstract class enables switching between local (standalone) and coordination modes:

                *
                  diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java index 0b8880620..88fa3dc55 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/NotificationClaimStrategy.java @@ -29,7 +29,7 @@ /** * Abstract base class for notification claiming strategies in different deployment modes. * Prevents duplicate notification sending when multiple Jenkins instances need to send - * feedback to Gerrit in HA/HS deployments. + * feedback to Gerrit in distributed scenarios. * *

                  This abstract class enables switching between local (standalone) and coordination modes:

                  *
                    diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/QueueCancellationStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/QueueCancellationStrategy.java index 10dc04e4b..3b8a763fc 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/QueueCancellationStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/QueueCancellationStrategy.java @@ -30,7 +30,7 @@ * Abstract base class for queue cancellation strategies in different deployment modes. * *

                    Determines whether a cancelled Jenkins queue item should be ignored because it was - * cancelled by the HA load balancer (i.e. migrated to another replica), not by a user + * cancelled by the distributed load balancer (i.e. migrated to another replica), not by a user * or by a new patchset event.

                    * *
                      @@ -45,10 +45,10 @@ public abstract class QueueCancellationStrategy { /** * Determines whether a cancelled queue item should be ignored because it was - * cancelled by the HA load balancer moving it to another replica. + * cancelled by the distributed load balancer moving it to another replica. * * @param item the queue item that left the queue as cancelled - * @return true if the cancellation is an HA load-balancing operation and should be skipped + * @return true if the cancellation is an distributed load-balancing operation and should be skipped */ public abstract boolean isLoadBalancedCancellation(@NonNull LeftItem item); } diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationHazelcastIntegrationTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationHazelcastIntegrationTest.java index b14608772..ad9d5974e 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationHazelcastIntegrationTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/BuildCancellationHazelcastIntegrationTest.java @@ -66,7 +66,7 @@ * These tests verify that build cancellation works correctly when using * Hazelcast-backed BuildMemoryStorage instead of local TreeMap storage. *

                      - * This is critical for HA/HS (High Availability/High Scalability) deployments where multiple Jenkins + * This is critical for distributed scenarios where multiple Jenkins * instances share state via Hazelcast. * */ From 70c66d13c8de34e63cb4f6c7280763dd537beff1 Mon Sep 17 00:00:00 2001 From: Robert Sandell Date: Thu, 2 Jul 2026 21:26:22 +0200 Subject: [PATCH 53/87] Remove faulty extension --- .mvn/extensions.xml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml index 9aff702b2..9440b1807 100644 --- a/.mvn/extensions.xml +++ b/.mvn/extensions.xml @@ -4,9 +4,4 @@ git-changelist-maven-extension 1.13 - - org.jenkins-ci.tools - maven-hpi-plugin - 3.1814.v77d15159f9b_d - From f29e7c79a1c523dc85518714604c3a83525e964d Mon Sep 17 00:00:00 2001 From: Robert Sandell Date: Thu, 2 Jul 2026 22:44:54 +0200 Subject: [PATCH 54/87] Move MemoryImprint <-> data conversion onto the model (Expert pattern) EntryData/MemoryImprintData were sitting in the hazelcast package, and all conversion to/from BuildMemory.MemoryImprint lived far away inside HazelcastBuildMemoryStorage.reconstructMemoryImprint(). That violated the Information Expert principle: MemoryImprint.Entry already stores the project and build as String identifiers (resolving Job/Run lazily), so it holds every field an EntryData needs. Changes: - Move EntryData and MemoryImprintData to the gerritnotifier.model package (core), so the API type can own the mapping without a backwards dependency on the hazelcast package. This also keeps them available to core if the Hazelcast layer is ever extracted to a separate plugin. - MemoryImprintData now carries the live GerritTriggeredEvent instead of a pre-serialized JSON string; the DTO is free of any storage/serialization concern. - Add Entry.toEntryData()/fromEntryData() and MemoryImprint.toData()/fromData() as straight field copies with no Jenkins lookups. - Move the event <-> JSON (Gson + PolymorphicEventTypeAdapter) logic into MemoryImprintDataSerializer, the only genuinely Hazelcast-specific boundary. The compact wire field ("eventJson") is unchanged, so no schema migration. - HazelcastBuildMemoryStorage drops reconstructMemoryImprint, serializeEvent, deserializeEvent and the shared Gson instance (~150 fewer lines). Behavioural notes (both improvements): - Timestamps are now preserved across a store/restore round-trip instead of being re-stamped to "now" by the setters during reconstruction. - Entries whose Job no longer resolves are retained (resolved lazily, as local storage already does) rather than silently dropped. Adds round-trip unit tests for the new mapping methods. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../hazelcast/EntryDataSerializer.java | 1 + .../HazelcastBuildMemoryStorage.java | 154 +++--------------- .../hazelcast/MemoryImprintData.java | 152 ----------------- .../MemoryImprintDataSerializer.java | 65 +++++++- .../gerritnotifier/model/BuildMemory.java | 94 +++++++++++ .../model}/EntryData.java | 28 ++-- .../model/MemoryImprintData.java | 119 ++++++++++++++ .../model/MemoryImprintTest.java | 65 ++++++++ 8 files changed, 377 insertions(+), 301 deletions(-) delete mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintData.java rename src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/{coordination/hazelcast => gerritnotifier/model}/EntryData.java (87%) create mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/MemoryImprintData.java diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java index 0a20e39b7..02fd3f851 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java @@ -26,6 +26,7 @@ import com.hazelcast.nio.serialization.compact.CompactReader; import com.hazelcast.nio.serialization.compact.CompactSerializer; import com.hazelcast.nio.serialization.compact.CompactWriter; +import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.EntryData; import edu.umd.cs.findbugs.annotations.NonNull; /** diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 31dad509d..29f8e2c71 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -26,14 +26,14 @@ */ package com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.hazelcast.core.HazelcastInstance; import com.hazelcast.map.IMap; import com.hazelcast.map.listener.EntryAddedListener; import com.sonyericsson.hudson.plugins.gerrit.trigger.diagnostics.BuildMemoryReport; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory.MemoryImprint; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildsStartedStats; +import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.EntryData; +import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.MemoryImprintData; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.AbandonedPatchsetInterruption; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.NewPatchSetInterruption; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.BuildMemoryStorage; @@ -79,27 +79,26 @@ *

                      * Serialization Strategy (MemoryImprint ↔ MemoryImprintData): *

                      - * This class handles conversion between the API type + * The API type * ({@link com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory.MemoryImprint}) - * and the serialization type ({@link MemoryImprintData}): + * knows how to convert itself to and from its plain data form + * ({@link MemoryImprintData}) via + * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory.MemoryImprint#toData()} + * and + * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory.MemoryImprint#fromData(MemoryImprintData)}: *

                        - *
                      • Write Path: Business logic → MemoryImprint → (convert) → MemoryImprintData → Hazelcast IMap
                      • - *
                      • Read Path: Hazelcast IMap → MemoryImprintData → (reconstruct) → MemoryImprint → Business logic
                      • + *
                      • Write Path: Business logic → MemoryImprint → {@code toData()} → MemoryImprintData → Hazelcast IMap
                      • + *
                      • Read Path: Hazelcast IMap → MemoryImprintData → {@code fromData()} → MemoryImprint → Business logic
                      • *
                      *

                      - * Conversion Details: - *

                        - *
                      • Event Serialization: {@link #serializeEvent} converts GerritTriggeredEvent to JSON - * using {@link PolymorphicEventTypeAdapter} for type preservation
                      • - *
                      • Reconstruction: {@link #reconstructMemoryImprint} deserializes JSON to events and - * looks up Jenkins objects via {@link jenkins.model.Jenkins#getItemByFullName}
                      • - *
                      - *

                      - * This conversion happens only at storage boundaries, keeping the rest of the plugin - * unaware of serialization concerns. + * The {@link MemoryImprintData} DTO carries the event as a live object; turning it into a JSON + * string on the wire (with polymorphic type preservation) is handled by + * {@link MemoryImprintDataSerializer} — the only Hazelcast-specific serialization boundary. This + * keeps both the API type and the DTO unaware of storage concerns. * * @see HazelcastCoordinationProvider * @see MemoryImprintData + * @see MemoryImprintDataSerializer * @see PolymorphicEventTypeAdapter */ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { @@ -177,14 +176,6 @@ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { */ private static final int LOCK_ACQUIRE_TIMEOUT_SECONDS = 10; - /** - * Gson instance for JSON serialization of events. - * Configured to handle polymorphic event types by including runtime type information. - */ - private static final Gson GSON = new GsonBuilder() - .registerTypeAdapter(GerritTriggeredEvent.class, new PolymorphicEventTypeAdapter()) - .create(); - /** * The Hazelcast instance to use for distributed storage. */ @@ -388,105 +379,6 @@ private static boolean tryLockWithTimeout(IMap map, S } } - /** - * Serializes a GerritTriggeredEvent to JSON. - * - * @param event the event to serialize - * @return JSON string, or null if serialization fails - */ - private String serializeEvent(GerritTriggeredEvent event) { - try { - // IMPORTANT: Must explicitly specify GerritTriggeredEvent.class to ensure - // the PolymorphicEventTypeAdapter is used, even when event is a concrete subclass - String json = GSON.toJson(event, GerritTriggeredEvent.class); - if (json != null) { - logger.trace("Serialized event {} to JSON (length: {})", event, json.length()); - } else { - logger.trace("Serialized event {} to JSON (length: 0)", event); - } - return json; - } catch (Exception e) { - logger.error("Failed to serialize event to JSON: " + event, e); - return null; - } - } - - /** - * Deserializes a GerritTriggeredEvent from JSON. - * - * @param eventJson the JSON string - * @return deserialized event, or null if deserialization fails - */ - private GerritTriggeredEvent deserializeEvent(String eventJson) { - try { - if (eventJson == null) { - logger.warn("Cannot deserialize null eventJson"); - return null; - } - GerritTriggeredEvent event = GSON.fromJson(eventJson, GerritTriggeredEvent.class); - logger.trace("Deserialized JSON (length: {}) to event: {}", eventJson.length(), event); - return event; - } catch (Exception e) { - if (eventJson != null) { - logger.error("Failed to deserialize event from JSON (length: " + eventJson.length() + ")", e); - } else { - logger.error("Failed to deserialize event from NULL JSON", e); - } - return null; - } - } - - /** - * Reconstructs a MemoryImprint from distributed data. - * - * @param event the event - * @param data the serialized data - * @return reconstructed MemoryImprint - */ - private MemoryImprint reconstructMemoryImprint(GerritTriggeredEvent event, MemoryImprintData data) { - MemoryImprint imprint = new MemoryImprint(event); - - if (data.getEntries() != null) { - Jenkins jenkins = Jenkins.getInstanceOrNull(); - if (jenkins == null) { - logger.warn("Jenkins instance not available, cannot reconstruct MemoryImprint"); - return imprint; - } - - for (EntryData entryData : data.getEntries()) { - String projectFullName = entryData.getProjectFullName(); - Job project = jenkins.getItemByFullName(projectFullName, Job.class); - - if (project != null) { - if (entryData.getBuildId() != null) { - Run build = project.getBuild(entryData.getBuildId()); - if (build != null) { - imprint.set(project, build, entryData.isBuildCompleted()); - } else { - // Build not found, but project exists - add entry without build - imprint.set(project); - } - } else { - // No build ID - project triggered but not started - imprint.set(project); - } - - // Restore additional entry data - MemoryImprint.Entry entry = imprint.getEntry(project); - if (entry != null) { - entry.setBuildCompleted(entryData.isBuildCompleted()); - entry.setCancelling(entryData.isCancelling()); - entry.setCancelled(entryData.isCancelled()); - entry.setCustomUrl(entryData.getCustomUrl()); - entry.setUnsuccessfulMessage(entryData.getUnsuccessfulMessage()); - } - } - } - } - - return imprint; - } - // ===== Implement BuildMemoryStorage abstract methods ===== @Override @@ -500,7 +392,7 @@ public synchronized MemoryImprint getMemoryImprint(@NonNull GerritTriggeredEvent String key = EventIdentifier.generateEventId(event); MemoryImprintData data = map.get(key); if (data != null) { - return reconstructMemoryImprint(event, data); + return MemoryImprint.fromData(data); } return null; } @@ -515,7 +407,6 @@ public synchronized void triggered(@NonNull GerritTriggeredEvent event, @NonNull String key = EventIdentifier.generateEventId(event); String projectFullName = project.getFullName(); - String eventJson = serializeEvent(event); // ATOMIC OPERATION - Distributed lock ensures only one replica modifies this entry at a time. // This is critical when multiple projects are triggered by the same event simultaneously. @@ -532,7 +423,7 @@ public synchronized void triggered(@NonNull GerritTriggeredEvent event, @NonNull MemoryImprintData data = map.get(key); if (data == null) { data = new MemoryImprintData(); - data.setEventJson(eventJson); + data.setEvent(event); } boolean found = false; if (data.getEntries() != null) { @@ -721,7 +612,6 @@ public synchronized void retriggered(@NonNull GerritTriggeredEvent event, @NonNu String key = EventIdentifier.generateEventId(event); String projectFullName = project.getFullName(); - String eventJson = serializeEvent(event); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). if (!tryLockWithTimeout(map, key)) { @@ -733,7 +623,7 @@ public synchronized void retriggered(@NonNull GerritTriggeredEvent event, @NonNu MemoryImprintData data = map.get(key); if (data == null) { data = new MemoryImprintData(); - data.setEventJson(eventJson); + data.setEvent(event); if (otherBuilds != null) { for (Run otherBuild : otherBuilds) { EntryData entryData = new EntryData(); @@ -1174,10 +1064,10 @@ public synchronized BuildMemoryReport report() { // Read all entries from distributed memory for (Map.Entry mapEntry : map.entrySet()) { MemoryImprintData data = mapEntry.getValue(); - GerritTriggeredEvent event = deserializeEvent(data.getEventJson()); + GerritTriggeredEvent event = data.getEvent(); if (event != null) { - MemoryImprint imprint = reconstructMemoryImprint(event, data); + MemoryImprint imprint = MemoryImprint.fromData(data); List triggered = new LinkedList(); for (MemoryImprint.Entry tr : imprint.getEntries()) { triggered.add(tr.clone()); @@ -1202,9 +1092,9 @@ public synchronized Map getAllEvents() { for (Map.Entry entry : map.entrySet()) { MemoryImprintData data = entry.getValue(); if (data != null) { - GerritTriggeredEvent event = deserializeEvent(data.getEventJson()); + GerritTriggeredEvent event = data.getEvent(); if (event != null) { - MemoryImprint imprint = reconstructMemoryImprint(event, data); + MemoryImprint imprint = MemoryImprint.fromData(data); result.put(event, imprint); } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintData.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintData.java deleted file mode 100644 index 2ac504516..000000000 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintData.java +++ /dev/null @@ -1,152 +0,0 @@ -/* - * The MIT License - * - * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; - -import java.util.ArrayList; -import java.util.List; - -/** - * Serializable data transfer object for BuildMemory storage in Hazelcast distributed maps. - *

                      - * Design Rationale - Why separate from MemoryImprint? - *

                      - * This class exists alongside - * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory.MemoryImprint} - * to separate API concerns from serialization concerns: - *

                        - *
                      • MemoryImprint: The main API class used by business logic throughout the plugin. - * Contains Jenkins objects ({@link hudson.model.Job}, {@link hudson.model.Run}, - * {@link com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent}) - * which are not serializable or cross-JVM compatible.
                      • - *
                      • MemoryImprintData: Serialization-optimized data structure for Hazelcast storage. - * Contains only primitives and strings (event JSON, project full names, build IDs) - * which can be safely serialized across JVM boundaries.
                      • - *
                      - *

                      - * Key Benefits of This Design: - *

                        - *
                      • Cross-JVM Compatibility: Uses Hazelcast Compact Serialization which works across - * different JVMs and classloaders (critical for sidecar deployment scenarios)
                      • - *
                      • API Stability: MemoryImprint API remains unchanged, preserving backward compatibility - * with existing code throughout the plugin
                      • - *
                      • No Object References: Avoids serializing Jenkins objects which may not exist on - * remote replicas or may change between serialization/deserialization
                      • - *
                      • Explicit Conversion: Forces explicit conversion at storage boundaries, making - * the serialization strategy visible and testable
                      • - *
                      - *

                      - * Conversion Strategy: - *

                        - *
                      • Storage: {@link HazelcastBuildMemoryStorage} converts MemoryImprint to MemoryImprintData - * by serializing events to JSON and extracting string identifiers (project names, build IDs)
                      • - *
                      • Retrieval: {@link HazelcastBuildMemoryStorage#reconstructMemoryImprint} converts - * MemoryImprintData back to MemoryImprint by deserializing events and looking up Jenkins - * objects via {@link jenkins.model.Jenkins#getItemByFullName}
                      • - *
                      - *

                      - * Alternative Considered and Rejected: - * Making MemoryImprint directly serializable was rejected because: - *

                        - *
                      • Jenkins objects (Job, Run) are not reliably serializable across replicas
                      • - *
                      • GerritTriggeredEvent requires custom polymorphic serialization
                      • - *
                      • Would break in sidecar scenarios where classloaders differ
                      • - *
                      • Would tightly couple the API to Hazelcast serialization details
                      • - *
                      - * - * @see HazelcastBuildMemoryStorage - * @see com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory.MemoryImprint - * @see MemoryImprintDataSerializer - */ -public class MemoryImprintData { - - private String eventJson; // JSON representation of GerritTriggeredEvent - private List entries; - - /** - * Default constructor. - */ - public MemoryImprintData() { - this.entries = new ArrayList<>(); - } - - /** - * Constructor with parameters. - * - * @param eventJson serialized event - * @param entries list of entry data - */ - public MemoryImprintData(String eventJson, List entries) { - this.eventJson = eventJson; - if (entries != null) { - this.entries = entries; - } else { - this.entries = new ArrayList<>(); - } - } - - /** - * Gets the serialized event JSON. - * - * @return event JSON string - */ - public String getEventJson() { - return eventJson; - } - - /** - * Sets the serialized event JSON. - * - * @param eventJson event JSON string - */ - public void setEventJson(String eventJson) { - this.eventJson = eventJson; - } - - /** - * Gets the list of entries. - * - * @return list of entry data - */ - public List getEntries() { - return entries; - } - - /** - * Sets the list of entries. - * - * @param entries list of entry data - */ - public void setEntries(List entries) { - this.entries = entries; - } - - /** - * Adds an entry to the list. - * - * @param entry the entry to add - */ - public void addEntry(EntryData entry) { - this.entries.add(entry); - } -} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintDataSerializer.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintDataSerializer.java index 6cb98c939..a58ecc0c0 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintDataSerializer.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/MemoryImprintDataSerializer.java @@ -23,10 +23,17 @@ */ package com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.hazelcast.nio.serialization.compact.CompactReader; import com.hazelcast.nio.serialization.compact.CompactSerializer; import com.hazelcast.nio.serialization.compact.CompactWriter; +import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.EntryData; +import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.MemoryImprintData; +import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; import edu.umd.cs.findbugs.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; @@ -37,20 +44,36 @@ * Compact Serialization is schema-based and doesn't require class definitions * on the Hazelcast server (sidecar container). This enables cross-JVM serialization * without classloading issues. + *

                      + * The {@link GerritTriggeredEvent} held by {@link MemoryImprintData} is a live object; this + * serializer is the boundary where it is turned into (and restored from) a JSON string on the + * wire, using {@link PolymorphicEventTypeAdapter} to preserve the concrete event subtype. Keeping + * this concern here means {@link MemoryImprintData} stays a plain, storage-agnostic DTO. * */ public class MemoryImprintDataSerializer implements CompactSerializer { + private static final Logger logger = LoggerFactory.getLogger(MemoryImprintDataSerializer.class); + /** * Type name for schema registration. * Uses fully-qualified name to prevent conflicts in shared Hazelcast clusters. */ private static final String TYPE_NAME = "com.sonyericsson.gerrit.trigger.MemoryImprintData"; + /** + * Gson instance for JSON serialization of events. + * Configured to handle polymorphic event types by including runtime type information. + */ + private static final Gson GSON = new GsonBuilder() + .registerTypeAdapter(GerritTriggeredEvent.class, new PolymorphicEventTypeAdapter()) + .create(); + @Override @NonNull public MemoryImprintData read(@NonNull CompactReader reader) { String eventJson = reader.readString("eventJson"); + GerritTriggeredEvent event = deserializeEvent(eventJson); // Read entries array using Compact Serialization array support EntryData[] entriesArray = reader.readArrayOfCompact("entries", EntryData.class); @@ -61,12 +84,12 @@ public MemoryImprintData read(@NonNull CompactReader reader) { } } - return new MemoryImprintData(eventJson, entries); + return new MemoryImprintData(event, entries); } @Override public void write(@NonNull CompactWriter writer, @NonNull MemoryImprintData data) { - writer.writeString("eventJson", data.getEventJson()); + writer.writeString("eventJson", serializeEvent(data.getEvent())); // Write entries array using Compact Serialization array support List entries = data.getEntries(); @@ -77,6 +100,44 @@ public void write(@NonNull CompactWriter writer, @NonNull MemoryImprintData data writer.writeArrayOfCompact("entries", entriesArray); } + /** + * Serializes a GerritTriggeredEvent to JSON. + * + * @param event the event to serialize, may be null + * @return JSON string, or null if the event is null or serialization fails + */ + private static String serializeEvent(GerritTriggeredEvent event) { + if (event == null) { + return null; + } + try { + // IMPORTANT: Must explicitly specify GerritTriggeredEvent.class to ensure + // the PolymorphicEventTypeAdapter is used, even when event is a concrete subclass. + return GSON.toJson(event, GerritTriggeredEvent.class); + } catch (Exception e) { + logger.error("Failed to serialize event to JSON: " + event, e); + return null; + } + } + + /** + * Deserializes a GerritTriggeredEvent from JSON. + * + * @param eventJson the JSON string, may be null + * @return deserialized event, or null if the JSON is null or deserialization fails + */ + private static GerritTriggeredEvent deserializeEvent(String eventJson) { + if (eventJson == null) { + return null; + } + try { + return GSON.fromJson(eventJson, GerritTriggeredEvent.class); + } catch (Exception e) { + logger.error("Failed to deserialize event from JSON (length: " + eventJson.length() + ")", e); + return null; + } + } + @Override @NonNull public String getTypeName() { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index ac10dd47a..a5884c56b 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -776,6 +776,41 @@ public List getEntriesList() { return list; } + /** + * Converts this imprint to its serialization-friendly {@link MemoryImprintData} form. + *

                      + * The event is carried as-is; each entry is mapped via {@link Entry#toEntryData()}. + * No Jenkins lookups or serialization happen here — turning the event into a wire + * representation is left to the storage layer. + * + * @return the data representation of this imprint. + * @see #fromData(MemoryImprintData) + */ + public synchronized MemoryImprintData toData() { + List entries = new ArrayList(); + for (Entry entry : list) { + entries.add(entry.toEntryData()); + } + return new MemoryImprintData(event, entries); + } + + /** + * Restores a {@link MemoryImprint} from its {@link MemoryImprintData} form. + * + * @param data the data to restore from. + * @return the reconstructed imprint. + * @see #toData() + */ + public static MemoryImprint fromData(@NonNull MemoryImprintData data) { + MemoryImprint imprint = new MemoryImprint(data.getEvent()); + if (data.getEntries() != null) { + for (EntryData entryData : data.getEntries()) { + imprint.list.add(Entry.fromEntryData(entryData)); + } + } + return imprint; + } + /** * Sets the build to a project or adds the project to the list. * @@ -1166,6 +1201,65 @@ public Entry clone() { return new Entry(this); } + /** + * Constructor that restores an entry from its {@link EntryData} form. + *

                      + * All fields are copied verbatim, including the timestamps. Unlike the + * {@link #setBuild(Run)}/{@link #setBuildCompleted(boolean)} setters, this does not + * re-stamp {@code startedTimestamp}/{@code completedTimestamp} with the current time, + * so the original moments are preserved across a store/restore round-trip. + * + * @param data the data to restore from. + * @see #fromEntryData(EntryData) + */ + private Entry(EntryData data) { + this.project = data.getProjectFullName(); + this.build = data.getBuildId(); + this.buildCompleted = data.isBuildCompleted(); + this.cancelling = data.isCancelling(); + this.cancelled = data.isCancelled(); + this.customUrl = data.getCustomUrl(); + this.unsuccessfulMessage = data.getUnsuccessfulMessage(); + this.triggeredTimestamp = data.getTriggeredTimestamp(); + this.completedTimestamp = data.getCompletedTimestamp(); + this.startedTimestamp = data.getStartedTimestamp(); + } + + /** + * Converts this entry to its serialization-friendly {@link EntryData} form. + *

                      + * This is a straight field copy with no Jenkins lookups: the entry already holds the + * project and build as {@code String} identifiers. + * + * @return the data representation of this entry. + * @see #fromEntryData(EntryData) + */ + public EntryData toEntryData() { + EntryData data = new EntryData(); + data.setProjectFullName(project); + data.setBuildId(build); + data.setBuildCompleted(buildCompleted); + data.setCancelling(cancelling); + data.setCancelled(cancelled); + data.setCustomUrl(customUrl); + data.setUnsuccessfulMessage(unsuccessfulMessage); + data.setTriggeredTimestamp(triggeredTimestamp); + data.setCompletedTimestamp(completedTimestamp); + data.setStartedTimestamp(startedTimestamp); + return data; + } + + /** + * Restores an {@link Entry} from its {@link EntryData} form. + * + * @param data the data to restore from. + * @return the reconstructed entry. + * @see #toEntryData() + */ + public static Entry fromEntryData(@NonNull EntryData data) { + return new Entry(data); + } + /** * The Project. * diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/EntryData.java similarity index 87% rename from src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java rename to src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/EntryData.java index 0dd719f7a..1162f786b 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/EntryData.java @@ -21,30 +21,28 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ -package com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; +package com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model; import edu.umd.cs.findbugs.annotations.CheckForNull; /** - * Serializable data transfer object for BuildMemory Entry in Hazelcast distributed storage. - *

                      - * This class is the serialization-friendly counterpart to + * Plain data transfer object mirroring the state of a * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory.MemoryImprint.Entry}. - * It stores the same information but uses only primitives and strings instead of Jenkins object references. *

                      - * Stored Data: - *

                        - *
                      • projectFullName: String identifier for the job (instead of {@link hudson.model.Job} reference)
                      • - *
                      • buildId: String identifier for the build (instead of {@link hudson.model.Run} reference)
                      • - *
                      • Build state: completion status, cancellation flags, timestamps
                      • - *
                      • Feedback data: custom URLs and unsuccessful messages for Gerrit comments
                      • - *
                      + * It carries only primitives and strings, so it can be serialized and shipped across JVMs + * (for distributed storage backends) without dragging in Jenkins object references. + * Note that {@code Entry} itself already stores the project and build as {@code String} + * identifiers and resolves the live {@link hudson.model.Job}/{@link hudson.model.Run} + * lazily, so the conversion is a straight field copy — see + * {@link BuildMemory.MemoryImprint.Entry#toEntryData()} and + * {@link BuildMemory.MemoryImprint.Entry#fromEntryData(EntryData)}. *

                      - * Uses Hazelcast Compact Serialization for cross-JVM compatibility in sidecar deployments. + * This class is intentionally free of any storage-technology dependency. Concerns such as + * wire serialization live in the storage layer (e.g. the Hazelcast compact serializer), + * keeping this type reusable and easy to extract. * * @see MemoryImprintData - * @see HazelcastBuildMemoryStorage#reconstructMemoryImprint - * @see EntryDataSerializer + * @see BuildMemory.MemoryImprint.Entry */ public class EntryData { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/MemoryImprintData.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/MemoryImprintData.java new file mode 100644 index 000000000..a47d1611f --- /dev/null +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/MemoryImprintData.java @@ -0,0 +1,119 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model; + +import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; + +import java.util.ArrayList; +import java.util.List; + +/** + * Plain data transfer object mirroring the state of a + * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory.MemoryImprint}. + *

                      + * It holds the triggering {@link GerritTriggeredEvent} together with a list of {@link EntryData}. + * Conversion to and from {@code MemoryImprint} is a straight structural copy provided by + * {@link BuildMemory.MemoryImprint#toData()} and {@link BuildMemory.MemoryImprint#fromData(MemoryImprintData)}; + * neither performs any Jenkins lookups. + *

                      + * Serialization is not this type's concern. The event is kept as a live object here. + * Turning it into a storage/wire representation (for example JSON, for distributed backends) + * is the responsibility of the storage layer's serializer, so this DTO stays free of any + * storage-technology dependency and is easy to reuse or extract. + * + * @see EntryData + * @see BuildMemory.MemoryImprint + */ +public class MemoryImprintData { + + private GerritTriggeredEvent event; + private List entries; + + /** + * Default constructor. + */ + public MemoryImprintData() { + this.entries = new ArrayList<>(); + } + + /** + * Constructor with parameters. + * + * @param event the triggering event + * @param entries list of entry data + */ + public MemoryImprintData(GerritTriggeredEvent event, List entries) { + this.event = event; + if (entries != null) { + this.entries = entries; + } else { + this.entries = new ArrayList<>(); + } + } + + /** + * Gets the triggering event. + * + * @return the event + */ + public GerritTriggeredEvent getEvent() { + return event; + } + + /** + * Sets the triggering event. + * + * @param event the event + */ + public void setEvent(GerritTriggeredEvent event) { + this.event = event; + } + + /** + * Gets the list of entries. + * + * @return list of entry data + */ + public List getEntries() { + return entries; + } + + /** + * Sets the list of entries. + * + * @param entries list of entry data + */ + public void setEntries(List entries) { + this.entries = entries; + } + + /** + * Adds an entry to the list. + * + * @param entry the entry to add + */ + public void addEntry(EntryData entry) { + this.entries.add(entry); + } +} diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/MemoryImprintTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/MemoryImprintTest.java index 65cdfeb41..076760acc 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/MemoryImprintTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/MemoryImprintTest.java @@ -25,6 +25,7 @@ package com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model; import com.sonyericsson.hudson.plugins.gerrit.trigger.mock.Setup; +import com.sonymobile.tools.gerrit.gerritevents.dto.events.PatchsetCreated; import hudson.model.AbstractBuild; import hudson.model.AbstractProject; import hudson.model.Job; @@ -37,6 +38,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.doReturn; @@ -51,6 +54,10 @@ */ public class MemoryImprintTest { + private static final long TRIGGERED_TS = 1000L; + private static final long STARTED_TS = 2000L; + private static final long COMPLETED_TS = 3000L; + private static int nameCount = 0; private AbstractProject project; private AbstractBuild build; @@ -174,4 +181,62 @@ public void testResetTwoPreviousBuilds() { assertNull(imprint.getEntries()[1].getBuild()); assertFalse(imprint.getEntries()[0].isBuildCompleted()); } + + /** + * Round-trips an {@link EntryData} through {@link BuildMemory.MemoryImprint.Entry#fromEntryData(EntryData)} + * and {@link BuildMemory.MemoryImprint.Entry#toEntryData()} and asserts every field is preserved. + *

                      + * Notably the timestamps are carried verbatim: unlike the {@code setBuild}/{@code setBuildCompleted} + * setters, the data round-trip must not re-stamp them with the current time. + */ + @Test + public void testEntryDataRoundTrip() { + EntryData data = new EntryData(); + data.setProjectFullName("some/project"); + data.setBuildId("42"); + data.setBuildCompleted(true); + data.setCancelling(true); + data.setCancelled(true); + data.setCustomUrl("http://example.test/custom"); + data.setUnsuccessfulMessage("nope"); + data.setTriggeredTimestamp(TRIGGERED_TS); + data.setStartedTimestamp(STARTED_TS); + data.setCompletedTimestamp(COMPLETED_TS); + + EntryData roundTripped = BuildMemory.MemoryImprint.Entry.fromEntryData(data).toEntryData(); + + assertEquals("some/project", roundTripped.getProjectFullName()); + assertEquals("42", roundTripped.getBuildId()); + assertTrue(roundTripped.isBuildCompleted()); + assertTrue(roundTripped.isCancelling()); + assertTrue(roundTripped.isCancelled()); + assertEquals("http://example.test/custom", roundTripped.getCustomUrl()); + assertEquals("nope", roundTripped.getUnsuccessfulMessage()); + assertEquals(TRIGGERED_TS, roundTripped.getTriggeredTimestamp()); + assertEquals(Long.valueOf(STARTED_TS), roundTripped.getStartedTimestamp()); + assertEquals(Long.valueOf(COMPLETED_TS), roundTripped.getCompletedTimestamp()); + } + + /** + * Round-trips a {@link BuildMemory.MemoryImprint} through {@link BuildMemory.MemoryImprint#toData()} + * and {@link BuildMemory.MemoryImprint#fromData(MemoryImprintData)} and asserts the event and entries + * survive intact, resolving back to the same Jenkins objects. + */ + @Test + public void testMemoryImprintRoundTrip() { + PatchsetCreated event = Setup.createPatchsetCreated(); + BuildMemory.MemoryImprint imprint = new BuildMemory.MemoryImprint(event); + imprint.set(project, build, true); + imprint.getEntry(project).setCustomUrl("http://example.test/x"); + + BuildMemory.MemoryImprint restored = BuildMemory.MemoryImprint.fromData(imprint.toData()); + + assertSame(event, restored.getEvent()); + assertEquals(1, restored.getEntries().length); + BuildMemory.MemoryImprint.Entry entry = restored.getEntries()[0]; + assertEquals(project, entry.getProject()); + assertEquals(build, entry.getBuild()); + assertTrue(entry.isBuildCompleted()); + assertEquals("http://example.test/x", entry.getCustomUrl()); + } } From ee6157f5d00b22706d52f4f3b543216892e5f950 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Wed, 15 Jul 2026 08:04:34 +0200 Subject: [PATCH 55/87] Fixing regresion bug --- .../HazelcastBuildMemoryStorage.java | 23 ++++++---- .../HazelcastQueueCancellationStrategy.java | 45 ++++++++----------- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 31dad509d..8c47fa767 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -613,6 +613,7 @@ public synchronized void started(@NonNull GerritTriggeredEvent event, @NonNull R newEntry.setProjectFullName(projectFullName); newEntry.setBuildId(buildId); newEntry.setStartedTimestamp(startedTimestamp); + data.setEventJson(serializeEvent(event)); data.addEntry(newEntry); } map.put(key, data); @@ -695,6 +696,7 @@ public synchronized void completed(@NonNull GerritTriggeredEvent event, @NonNull newEntry.setBuildId(buildId); newEntry.setCompletedTimestamp(completedTimestamp); newEntry.setBuildCompleted(true); + data.setEventJson(serializeEvent(event)); data.addEntry(newEntry); } map.put(key, data); @@ -801,15 +803,18 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull for (EntryData entryData : data.getEntries()) { if (projectFullName.equals(entryData.getProjectFullName())) { found = true; - // Mark as cancelled unconditionally. Load-balanced cancellations - // (QueueLoadBalancer moving items between replicas) are already - // filtered out upstream by GerritQueueListener.isLoadBalancedCancellation() - // before cancelled() is ever called. - entryData.setCancelled(true); - entryData.setCancelling(false); - entryData.setCompletedTimestamp(cancelledTimestamp); - entryData.setBuildCompleted(true); - modified = true; + if (entryData.isCancelling()) { + entryData.setCancelled(true); + entryData.setCancelling(false); + entryData.setCompletedTimestamp(cancelledTimestamp); + entryData.setBuildCompleted(true); + modified = true; + } else { + logger.debug("Skipping cancelled() for project={} event={}: " + + "isCancelling=false, buildId={}. Not explicitly marked for cancellation " + + "(likely external cancellation e.g. QueueLoadBalancer); not marking as completed.", + projectFullName, key, entryData.getBuildId()); + } break; } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastQueueCancellationStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastQueueCancellationStrategy.java index 0072e4ea2..4420d8a7a 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastQueueCancellationStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastQueueCancellationStrategy.java @@ -27,50 +27,41 @@ import edu.umd.cs.findbugs.annotations.NonNull; import hudson.model.Queue.LeftItem; -import java.util.logging.Logger; - /** * Hazelcast (distributed) implementation of QueueCancellationStrategy. * - *

                      Detects queue item cancellations triggered by the potential distributed load balancer - * (QueueLoadBalancer), which moves queue items between replicas by cancelling the - * original and re-queuing it on the target replica. These cancellations must be - * ignored to avoid sending premature "build cancelled" feedback to Gerrit.

                      - * - *

                      Class names are checked via string matching to avoid a mandatory compile-time - * dependency on any other kind of replication plugin.

                      + *

                      Protection against external queue cancellations (e.g. CloudBees QueueLoadBalancer + * moving items between replicas) is handled by the {@code isCancelling()} guard in + * {@link HazelcastBuildMemoryStorage#cancelled} — only entries explicitly flagged by + * {@code cancelOutdatedEvents()} are marked as completed.

                      * * @see com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastCoordinationProvider * @see QueueCancellationStrategy */ public class HazelcastQueueCancellationStrategy extends QueueCancellationStrategy { - private static final Logger logger = Logger.getLogger(HazelcastQueueCancellationStrategy.class.getName()); - /** - * Returns true if the cancelled item was moved by the distributed load balancer. + * Returns false unconditionally. * - *

                      Two markers are checked (either is sufficient):

                      + *

                      CloudBees {@code CancelQueueItem} calls {@code Queue.cancel(item)} with no markers + * attached to the resulting {@code LeftItem}:

                      *
                        - *
                      • {@code QueueLoadBalancerAction} in the item's actions — present on the - * new queue item created on the target replica.
                      • - *
                      • {@code LoadBalancedCauseOfBlockage} as cause-of-blockage — present on - * the original item cancelled by {@code CancelQueueItem}.
                      • + *
                      • {@code QueueLoadBalancerAction} is attached to the NEW item on the target replica + * (inside {@code QueueRequest} executed remotely), never to the item being cancelled.
                      • + *
                      • {@code LoadBalancedCauseOfBlockage} is a {@code BlockedItem.causeOfBlockage} that + * Jenkins does not copy into {@code LeftItem} — {@code LeftItem.getCauseOfBlockage()} + * returns null for load-balanced cancellations.
                      • *
                      * + *

                      Confirmed via bytecode analysis of {@code cloudbees-replication.jar} v2656. + * The real protection is the {@code isCancelling()} guard in + * {@link HazelcastBuildMemoryStorage#cancelled}.

                      + * * @param item the queue item that left the queue as cancelled - * @return true if cancelled by the distributed load balancer + * @return always false */ @Override public boolean isLoadBalancedCancellation(@NonNull LeftItem item) { - boolean result = item.getActions().stream() - .anyMatch(a -> a.getClass().getName().contains("QueueLoadBalancerAction")) - || (item.getCauseOfBlockage() != null - && item.getCauseOfBlockage().getClass().getName() - .contains("LoadBalancedCauseOfBlockage")); - if (result) { - logger.fine("Queue item cancelled due to distributed load balancing, skipping Gerrit cancellation: " + item); - } - return result; + return false; } } From bb35c8338001c5b84f905ce754298b8fee0c639f Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Thu, 16 Jul 2026 08:28:13 +0200 Subject: [PATCH 56/87] Fixing only client mode for Hazelcast maven test profile --- .../EmbeddedHazelcastTestServer.java | 132 ++++++++++++++++++ .../HazelcastServerTestListener.java | 74 ++++++++++ .../hazelcast/HazelcastTestListener.java | 5 + ...it.platform.launcher.TestExecutionListener | 1 + 4 files changed, 212 insertions(+) create mode 100644 src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EmbeddedHazelcastTestServer.java create mode 100644 src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastServerTestListener.java create mode 100644 src/test/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EmbeddedHazelcastTestServer.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EmbeddedHazelcastTestServer.java new file mode 100644 index 000000000..ea7bc5909 --- /dev/null +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EmbeddedHazelcastTestServer.java @@ -0,0 +1,132 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.hazelcast.config.Config; +import com.hazelcast.config.JoinConfig; +import com.hazelcast.config.NetworkConfig; +import com.hazelcast.core.Hazelcast; +import com.hazelcast.core.HazelcastInstance; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Starts an embedded Hazelcast member (server) in the test JVM so that + * {@link HazelcastManager#initialize()} (which creates a client) has a server to connect to. + *

                      + * The server binds to {@code localhost:5702} — the same address the client uses by default + * ({@link HazelcastConfig#DEFAULT_CLIENT_ADDRESS}). + *

                      + * This is required because since member mode was removed the plugin only creates a + * Hazelcast client, so tests must supply the server themselves. + */ +public final class EmbeddedHazelcastTestServer { + + private static final Logger logger = LoggerFactory.getLogger(EmbeddedHazelcastTestServer.class); + + private static final int TEST_PORT = 5702; + + private static volatile HazelcastInstance serverInstance = null; + private static final Object LOCK = new Object(); + + private EmbeddedHazelcastTestServer() { + // utility class + } + + /** + * Starts the embedded Hazelcast server if not already running. + * Idempotent — safe to call multiple times. + */ + public static void start() { + synchronized (LOCK) { + if (serverInstance != null && serverInstance.getLifecycleService().isRunning()) { + logger.debug("Embedded Hazelcast test server already running"); + return; + } + + logger.info("Starting embedded Hazelcast test server on localhost:{}", TEST_PORT); + try { + Config config = buildServerConfig(); + serverInstance = Hazelcast.newHazelcastInstance(config); + logger.info("Embedded Hazelcast test server started: {}", serverInstance.getName()); + } catch (Exception e) { + logger.error("Failed to start embedded Hazelcast test server", e); + throw new RuntimeException("Failed to start embedded Hazelcast test server", e); + } + } + } + + /** + * Shuts down the embedded Hazelcast server if running. + * Idempotent — safe to call multiple times. + */ + public static void stop() { + synchronized (LOCK) { + if (serverInstance == null) { + return; + } + try { + logger.info("Stopping embedded Hazelcast test server"); + serverInstance.shutdown(); + logger.info("Embedded Hazelcast test server stopped"); + } catch (Exception e) { + logger.warn("Error stopping embedded Hazelcast test server", e); + } finally { + serverInstance = null; + } + } + } + + /** + * Returns true if the server is running. + * + * @return true if running + */ + public static boolean isRunning() { + HazelcastInstance current = serverInstance; + return current != null && current.getLifecycleService().isRunning(); + } + + private static Config buildServerConfig() { + Config config = new Config(); + + config.setClusterName(HazelcastConfig.DEFAULT_CLUSTER_NAME); + config.setInstanceName("gerrit-trigger-test-server"); + config.setProperty("hazelcast.logging.type", "slf4j"); + // Suppress startup banner in test output + config.setProperty("hazelcast.shutdownhook.enabled", "false"); + + NetworkConfig network = config.getNetworkConfig(); + network.setPort(TEST_PORT); + network.setPortAutoIncrement(false); + network.getInterfaces().setEnabled(true).addInterface("127.0.0.1"); + + // TCP-IP with only localhost — no multicast, no Kubernetes discovery + JoinConfig join = network.getJoin(); + join.getMulticastConfig().setEnabled(false); + join.getTcpIpConfig().setEnabled(true).addMember("127.0.0.1:" + TEST_PORT); + + return config; + } +} diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastServerTestListener.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastServerTestListener.java new file mode 100644 index 000000000..b68de456e --- /dev/null +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastServerTestListener.java @@ -0,0 +1,74 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import org.junit.platform.launcher.TestExecutionListener; +import org.junit.platform.launcher.TestPlan; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * JUnit Platform {@link TestExecutionListener} that starts an embedded Hazelcast server + * before any test in the forked JVM runs. + *

                      + * Activated by Maven Surefire when the {@code test-hazelcast} profile is active. This listener + * is discovered via the {@code META-INF/services/org.junit.platform.launcher.TestExecutionListener} + * service file, which is the correct discovery mechanism for the JUnit Platform provider (surefire + * 3.x) — unlike the JUnit 4 {@code } property, which is not invoked for JUnit 5 tests. + *

                      + * Starting the server here ensures that when Jenkins initialises and + * {@code PluginImpl.gerritStart()} calls {@link HazelcastManager#initialize()} (which creates a + * Hazelcast client connecting to {@code localhost:5702}), the server is already listening. + * Without this, the client hangs for several minutes trying to reach a non-existent server. + * + * @see EmbeddedHazelcastTestServer + */ +public class HazelcastServerTestListener implements TestExecutionListener { + + private static final Logger logger = LoggerFactory.getLogger(HazelcastServerTestListener.class); + + private static final String COORDINATION_MODE_PROPERTY = "gerrit.trigger.coordination.mode"; + private static final String HAZELCAST_MODE = "hazelcast"; + + @Override + public void testPlanExecutionStarted(TestPlan testPlan) { + String mode = System.getProperty(COORDINATION_MODE_PROPERTY); + if (!HAZELCAST_MODE.equalsIgnoreCase(mode)) { + logger.debug("Coordination mode is '{}', skipping embedded Hazelcast server start", mode); + return; + } + logger.info("=== Starting embedded Hazelcast test server (coordination mode: {}) ===", mode); + EmbeddedHazelcastTestServer.start(); + logger.info("=== Embedded Hazelcast test server ready ==="); + } + + @Override + public void testPlanExecutionFinished(TestPlan testPlan) { + if (EmbeddedHazelcastTestServer.isRunning()) { + logger.info("=== Stopping embedded Hazelcast test server ==="); + EmbeddedHazelcastTestServer.stop(); + logger.info("=== Embedded Hazelcast test server stopped ==="); + } + } +} diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestListener.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestListener.java index 7e8478bb6..9b2431411 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestListener.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastTestListener.java @@ -63,6 +63,11 @@ public void testRunStarted(Description description) { logger.info("=== Hazelcast Test Suite Initialization ==="); logger.info("Coordination mode property: {}", mode); + // Ensure the embedded server is running before the client tries to connect. + // HazelcastServerTestListener (JUnit Platform SPI) starts it for JUnit 5 tests, + // but this guard covers any JUnit 4 fork where the platform listener did not fire. + EmbeddedHazelcastTestServer.start(); + if (!HazelcastManager.isInitialized()) { try { logger.info("Initializing Hazelcast for test suite..."); diff --git a/src/test/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener b/src/test/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener new file mode 100644 index 000000000..626942932 --- /dev/null +++ b/src/test/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener @@ -0,0 +1 @@ +com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastServerTestListener From 2f1f5c4ebc8c6a978af1e8479a29898e68d5d2f8 Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Thu, 16 Jul 2026 08:28:39 +0200 Subject: [PATCH 57/87] Fixing only client mode for Hazelcast maven test profile --- pom.xml | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/pom.xml b/pom.xml index 4b1e50bc2..586191f65 100644 --- a/pom.xml +++ b/pom.xml @@ -392,9 +392,11 @@ - HazelcastBuildMemoryStorage works correctly - Event and notification claiming strategies work - Note: Tests may fail during Hazelcast initialization if network configuration - is not available (e.g., no Kubernetes or TCP members configured). This is expected - behavior - the plugin will fall back to local mode and log warnings. + An embedded Hazelcast server is automatically started before any tests run by + HazelcastServerTestListener (discovered via + META-INF/services/org.junit.platform.launcher.TestExecutionListener). This provides + the localhost:5702 endpoint that the plugin client connects to, with no external + Kubernetes or TCP infrastructure required. --> test-hazelcast @@ -408,16 +410,7 @@ hazelcast - - multicast - - - - listener - com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastTestListener - - From 1c4a0fd0458e024e0bd049351310cac67fb6d7cb Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Thu, 16 Jul 2026 14:49:38 +0200 Subject: [PATCH 58/87] Fix 004 test regarding cancellation policies --- .../coordination/hazelcast/EntryData.java | 29 ++++++++++ .../hazelcast/EntryDataSerializer.java | 2 + .../HazelcastBuildMemoryStorage.java | 56 +++++++++++++++---- .../HazelcastQueueCancellationStrategy.java | 13 +---- .../gerritnotifier/model/BuildMemory.java | 36 +++++++++++- .../storage/LocalBuildMemoryStorage.java | 3 + 6 files changed, 116 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java index 0dd719f7a..cda5c3ca9 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java @@ -53,6 +53,7 @@ public class EntryData { private boolean buildCompleted; private boolean cancelling; private boolean cancelled; + private boolean queueLeft; private String customUrl; private String unsuccessfulMessage; private long triggeredTimestamp; @@ -177,6 +178,34 @@ public void setCancelled(boolean cancelled) { this.cancelled = cancelled; } + /** + * Checks if the queue item left the queue without a prior Gerrit-triggered cancellation intent. + *

                      + * This flag covers two cases that look identical at {@code QueueListener.onLeft} time: + *

                        + *
                      • CloudBees load-balanced move — item moved to another replica; the build will appear + * again on that replica via {@code onStarted}.
                      • + *
                      • Direct {@code Queue.doCancelItem} call without a preceding {@code setCancelling} — + * item truly removed but without going through the normal Gerrit cancellation path.
                      • + *
                      + * Unlike {@link #isCancelled()}, this flag does NOT set {@link #isBuildCompleted()}, so the + * IMap entry is preserved for cross-replica PS2-aborts-PS1 scenarios. + * + * @return true if the queue item left without a prior cancelling intent + */ + public boolean isQueueLeft() { + return queueLeft; + } + + /** + * Sets the queueLeft flag. + * + * @param queueLeft true if the queue item left without a prior cancelling intent + */ + public void setQueueLeft(boolean queueLeft) { + this.queueLeft = queueLeft; + } + /** * Gets custom URL. * diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java index 0a20e39b7..c40370a49 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryDataSerializer.java @@ -51,6 +51,7 @@ public EntryData read(@NonNull CompactReader reader) { entry.setBuildCompleted(reader.readBoolean("buildCompleted")); entry.setCancelling(reader.readBoolean("cancelling")); entry.setCancelled(reader.readBoolean("cancelled")); + entry.setQueueLeft(reader.readBoolean("queueLeft")); entry.setCustomUrl(reader.readString("customUrl")); entry.setUnsuccessfulMessage(reader.readString("unsuccessfulMessage")); entry.setTriggeredTimestamp(reader.readInt64("triggeredTimestamp")); @@ -66,6 +67,7 @@ public void write(@NonNull CompactWriter writer, @NonNull EntryData entry) { writer.writeBoolean("buildCompleted", entry.isBuildCompleted()); writer.writeBoolean("cancelling", entry.isCancelling()); writer.writeBoolean("cancelled", entry.isCancelled()); + writer.writeBoolean("queueLeft", entry.isQueueLeft()); writer.writeString("customUrl", entry.getCustomUrl()); writer.writeString("unsuccessfulMessage", entry.getUnsuccessfulMessage()); writer.writeInt64("triggeredTimestamp", entry.getTriggeredTimestamp()); diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 8c47fa767..85c11f45c 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -477,6 +477,7 @@ private MemoryImprint reconstructMemoryImprint(GerritTriggeredEvent event, Memor entry.setBuildCompleted(entryData.isBuildCompleted()); entry.setCancelling(entryData.isCancelling()); entry.setCancelled(entryData.isCancelled()); + entry.setQueueLeft(entryData.isQueueLeft()); entry.setCustomUrl(entryData.getCustomUrl()); entry.setUnsuccessfulMessage(entryData.getUnsuccessfulMessage()); } @@ -598,6 +599,10 @@ public synchronized void started(@NonNull GerritTriggeredEvent event, @NonNull R if (projectFullName.equals(entryData.getProjectFullName())) { entryData.setBuildId(buildId); entryData.setStartedTimestamp(startedTimestamp); + // The build actually started on a replica — clear any queueLeft flag that + // was set when the queue item was moved by a load balancer. The entry is + // now actively building and must be visible to cancelOutdatedBuilds again. + entryData.setQueueLeft(false); found = true; // If this entry is already marked for cancellation, we need to trigger // cross-replica abort now that buildId is known. @@ -803,16 +808,33 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull for (EntryData entryData : data.getEntries()) { if (projectFullName.equals(entryData.getProjectFullName())) { found = true; - if (entryData.isCancelling()) { - entryData.setCancelled(true); - entryData.setCancelling(false); - entryData.setCompletedTimestamp(cancelledTimestamp); - entryData.setBuildCompleted(true); + if (!entryData.isBuildCompleted()) { + if (entryData.isCancelling()) { + // Gerrit-triggered cancellation: the intent was set via setCancelling() + // first (e.g. by cancelOutdatedBuilds). This is a real, deliberate cancel. + entryData.setCancelled(true); + entryData.setCancelling(false); + entryData.setCompletedTimestamp(cancelledTimestamp); + entryData.setBuildCompleted(true); + } else if (entryData.getBuildId() == null) { + // No prior cancellation intent AND build has not started anywhere. + // This is a CloudBees load-balanced queue move (build will restart + // on another replica) or a direct Queue.doCancelItem before start. + // Mark queueLeft=true but do NOT set buildCompleted=true so the + // IMap entry is preserved for cross-replica PS2-aborts-PS1 scenarios. + // NOTE: if buildId IS already set, started() already ran on another + // replica — leave the entry completely untouched so it stays visible + // to cancelOutdatedBuilds on that replica. + entryData.setQueueLeft(true); + } else { + logger.debug("cancelled() called after started() for project={} event={}: " + + "build already running (buildId={}), ignoring late onLeft.", + projectFullName, key, entryData.getBuildId()); + } modified = true; } else { logger.debug("Skipping cancelled() for project={} event={}: " - + "isCancelling=false, buildId={}. Not explicitly marked for cancellation " - + "(likely external cancellation e.g. QueueLoadBalancer); not marking as completed.", + + "already completed, buildId={}.", projectFullName, key, entryData.getBuildId()); } break; @@ -858,7 +880,8 @@ public synchronized void setCancelling(@NonNull GerritTriggeredEvent event, @Non boolean updated = false; for (EntryData entryData : data.getEntries()) { if (projectFullName.equals(entryData.getProjectFullName())) { - if (!entryData.isBuildCompleted() && !entryData.isCancelling() && !entryData.isCancelled()) { + if (!entryData.isBuildCompleted() && !entryData.isCancelling() + && !entryData.isCancelled() && !entryData.isQueueLeft()) { entryData.setCancelling(true); updated = true; } @@ -1045,7 +1068,7 @@ public synchronized boolean isBuilding(@NonNull GerritTriggeredEvent event, @Non if (entry.getBuild() != null) { return !entry.isBuildCompleted(); } else { - return !entry.isCancelling() && !entry.isCancelled(); + return !entry.isCancelling() && !entry.isCancelled() && !entry.isQueueLeft(); } } } @@ -1055,7 +1078,20 @@ public synchronized boolean isBuilding(@NonNull GerritTriggeredEvent event, @Non @Override public synchronized boolean isBuilding(@NonNull GerritTriggeredEvent event) { MemoryImprint imprint = getMemoryImprint(event); - return imprint != null; + if (imprint == null) { + return false; + } + // An event is still "building" if at least one entry is not yet in a terminal state. + // queueLeft entries (load-balanced moves or direct doCancelItem) are treated as + // inactive — they are not running on this replica. This allows isBuilding(event) + // to return false for the unit-test case (direct doCancelItem) while preserving + // the IMap key for cross-replica PS2-aborts-PS1 scenarios (HZ-004). + for (MemoryImprint.Entry entry : imprint.getEntries()) { + if (!entry.isBuildCompleted() && !entry.isQueueLeft()) { + return true; + } + } + return false; } @Override diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastQueueCancellationStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastQueueCancellationStrategy.java index 4420d8a7a..b45c0d38a 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastQueueCancellationStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastQueueCancellationStrategy.java @@ -30,11 +30,6 @@ /** * Hazelcast (distributed) implementation of QueueCancellationStrategy. * - *

                      Protection against external queue cancellations (e.g. CloudBees QueueLoadBalancer - * moving items between replicas) is handled by the {@code isCancelling()} guard in - * {@link HazelcastBuildMemoryStorage#cancelled} — only entries explicitly flagged by - * {@code cancelOutdatedEvents()} are marked as completed.

                      - * * @see com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastCoordinationProvider * @see QueueCancellationStrategy */ @@ -43,20 +38,16 @@ public class HazelcastQueueCancellationStrategy extends QueueCancellationStrateg /** * Returns false unconditionally. * - *

                      CloudBees {@code CancelQueueItem} calls {@code Queue.cancel(item)} with no markers + *

                      Potential {@code CancelQueueItem} calls {@code Queue.cancel(item)} with no markers * attached to the resulting {@code LeftItem}:

                      *
                        - *
                      • {@code QueueLoadBalancerAction} is attached to the NEW item on the target replica + *
                      • {@code QueueLoadBalancerAction} is attached to the NEW item on the target instance * (inside {@code QueueRequest} executed remotely), never to the item being cancelled.
                      • *
                      • {@code LoadBalancedCauseOfBlockage} is a {@code BlockedItem.causeOfBlockage} that * Jenkins does not copy into {@code LeftItem} — {@code LeftItem.getCauseOfBlockage()} * returns null for load-balanced cancellations.
                      • *
                      * - *

                      Confirmed via bytecode analysis of {@code cloudbees-replication.jar} v2656. - * The real protection is the {@code isCancelling()} guard in - * {@link HazelcastBuildMemoryStorage#cancelled}.

                      - * * @param item the queue item that left the queue as cancelled * @return always false */ diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index ac10dd47a..8d6fd5289 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -395,7 +395,8 @@ public void cancelOutdatedEvents( if (imprintEntry.isProject(jobName) && !imprintEntry.isBuildCompleted() && !imprintEntry.isCancelling() - && !imprintEntry.isCancelled()) { + && !imprintEntry.isCancelled() + && !imprintEntry.isQueueLeft()) { hasActiveBuildsForJob = true; logger.debug("Found active build for job {}", jobName); break; @@ -1061,7 +1062,7 @@ public synchronized boolean wereAllBuildsNotBuilt() { if (entry == null) { continue; } - if (entry.isCancelling() || entry.isCancelled()) { + if (entry.isCancelling() || entry.isCancelled() || entry.isQueueLeft()) { continue; } Run build = entry.getBuild(); @@ -1109,6 +1110,7 @@ public static class Entry implements Cloneable { private boolean buildCompleted; private boolean cancelling; private boolean cancelled; + private boolean queueLeft; private String customUrl; private String unsuccessfulMessage; private final long triggeredTimestamp; @@ -1159,6 +1161,7 @@ public Entry(Entry copy) { this.customUrl = copy.customUrl; this.cancelling = copy.cancelling; this.cancelled = copy.cancelled; + this.queueLeft = copy.queueLeft; } @Override @@ -1316,6 +1319,35 @@ public void setCancelled(boolean cancelled) { this.cancelled = cancelled; } + /** + * Whether the queue item left the queue without a prior Gerrit-triggered cancellation intent. + *

                      + * This flag covers two cases that are indistinguishable at {@code QueueListener.onLeft} time: + *

                        + *
                      • CloudBees load-balanced move — the item was moved to another replica; + * the build will reappear via {@code onStarted} on that replica.
                      • + *
                      • Direct {@code Queue.doCancelItem} without a preceding {@code setCancelling} — + * truly removed but not through the normal Gerrit cancellation path.
                      • + *
                      + * Unlike {@link #isCancelled()}, setting this flag does NOT also set + * {@link #setBuildCompleted(boolean)}, preserving the IMap entry for cross-replica + * new-patchset abort scenarios (HZ-004). + * + * @return true if the queue item left without a prior cancelling intent + */ + public boolean isQueueLeft() { + return queueLeft; + } + + /** + * Sets the queueLeft flag. + * + * @param queueLeft true if the queue item left without a prior cancelling intent + */ + public void setQueueLeft(boolean queueLeft) { + this.queueLeft = queueLeft; + } + /** * The timestamp when {@link #setBuildCompleted(boolean)} was set to true. * null indicates not completed yet. diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java index 68f6426c7..cc6840209 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/storage/LocalBuildMemoryStorage.java @@ -116,6 +116,9 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull MemoryImprint pb = getOrCreateMemoryImprint(event); pb.set(project); Entry entry = pb.getEntry(project); + // In local (single-replica) mode there are no load-balanced queue moves, so any + // cancelled() call is a genuine cancellation. Always mark buildCompleted=true so that + // isAllBuildsCompleted() can fire Gerrit feedback when the remaining jobs finish. entry.setCancelled(true); entry.setCancelling(false); entry.setBuildCompleted(true); From 571c59cc7194118124ef8c3b1a75b551f342e67b Mon Sep 17 00:00:00 2001 From: Ignacio Roncero Date: Thu, 16 Jul 2026 16:08:06 +0200 Subject: [PATCH 59/87] Fixing javadoc issues --- .../trigger/coordination/hazelcast/EntryData.java | 6 +++--- .../hazelcast/HazelcastBuildMemoryStorage.java | 12 ++++++------ .../trigger/gerritnotifier/model/BuildMemory.java | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java index cda5c3ca9..ddc00d308 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EntryData.java @@ -183,9 +183,9 @@ public void setCancelled(boolean cancelled) { *

                      * This flag covers two cases that look identical at {@code QueueListener.onLeft} time: *

                        - *
                      • CloudBees load-balanced move — item moved to another replica; the build will appear - * again on that replica via {@code onStarted}.
                      • - *
                      • Direct {@code Queue.doCancelItem} call without a preceding {@code setCancelling} — + *
                      • Potential load-balanced move — item moved to another instance; the build will appear + * again on that instance via {@code onStarted}.
                      • + *
                      • Direct queue item cancellation without a preceding {@code setCancelling} — * item truly removed but without going through the normal Gerrit cancellation path.
                      • *
                      * Unlike {@link #isCancelled()}, this flag does NOT set {@link #isBuildCompleted()}, so the diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 85c11f45c..b1b908b9a 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -149,7 +149,7 @@ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { * When {@code started()} detects that an entry is already marked {@code isCancelling=true} * (race condition: build started after the abort decision was made), the abort inbox entry * is written after this delay so the CPS engine has had time to attach a - * {@link org.jenkinsci.plugins.workflow.flow.FlowExecution} before the interrupt arrives. + * FlowExecution before the interrupt arrives. */ private static final long DEFERRED_ABORT_DELAY_SECONDS = 3L; @@ -818,13 +818,13 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull entryData.setBuildCompleted(true); } else if (entryData.getBuildId() == null) { // No prior cancellation intent AND build has not started anywhere. - // This is a CloudBees load-balanced queue move (build will restart - // on another replica) or a direct Queue.doCancelItem before start. + // This is a potential load-balanced queue move (build will restart + // on another instance) or a direct Queue.doCancelItem before start. // Mark queueLeft=true but do NOT set buildCompleted=true so the - // IMap entry is preserved for cross-replica PS2-aborts-PS1 scenarios. + // IMap entry is preserved for cross-instance PS2-aborts-PS1 scenarios. // NOTE: if buildId IS already set, started() already ran on another - // replica — leave the entry completely untouched so it stays visible - // to cancelOutdatedBuilds on that replica. + // instance — leave the entry completely untouched so it stays visible + // to cancelOutdatedBuilds on that instance. entryData.setQueueLeft(true); } else { logger.debug("cancelled() called after started() for project={} event={}: " diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index 8d6fd5289..39254356e 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -1324,13 +1324,13 @@ public void setCancelled(boolean cancelled) { *

                      * This flag covers two cases that are indistinguishable at {@code QueueListener.onLeft} time: *

                        - *
                      • CloudBees load-balanced move — the item was moved to another replica; - * the build will reappear via {@code onStarted} on that replica.
                      • + *
                      • Potential load-balanced move — the item was moved to another instance; + * the build will reappear via {@code onStarted} on that instance.
                      • *
                      • Direct {@code Queue.doCancelItem} without a preceding {@code setCancelling} — * truly removed but not through the normal Gerrit cancellation path.
                      • *
                      * Unlike {@link #isCancelled()}, setting this flag does NOT also set - * {@link #setBuildCompleted(boolean)}, preserving the IMap entry for cross-replica + * {@link #setBuildCompleted(boolean)}, preserving the IMap entry for cross-instance * new-patchset abort scenarios (HZ-004). * * @return true if the queue item left without a prior cancelling intent From 5b3688869b3739dfc47f983839a5ae330601e594 Mon Sep 17 00:00:00 2001 From: Robert Sandell Date: Sat, 18 Jul 2026 22:53:08 +0200 Subject: [PATCH 60/87] Map queueLeft through the Entry <-> EntryData conversion The merge with hazelcast-impl brought in the new queueLeft flag (EntryData, its compact serializer, the storage write/read sites and BuildMemory.Entry's business-logic checks), but the Expert-pattern mapping methods introduced by this branch were not updated to carry it. Entry.fromEntryData() (via the Entry(EntryData) constructor) and Entry.toEntryData() replaced the old reconstructMemoryImprint(), which had done entry.setQueueLeft(...), so the flag was silently dropped on every store/restore round-trip. Effect of the bug: on the read path (getMemoryImprint/report/getAllEvents -> fromData -> fromEntryData) a stored queueLeft=true came back as false, so BuildMemory cancellation logic that inspects entry.isQueueLeft() on the reconstructed imprint never saw it - defeating the HZ-004 cross-replica new-patchset-abort behaviour the flag exists for. Map queueLeft in both directions and extend the round-trip test to cover it. --- .../gerrit/trigger/gerritnotifier/model/BuildMemory.java | 2 ++ .../gerrit/trigger/gerritnotifier/model/MemoryImprintTest.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index d8e3ffa2e..eef9ba1d0 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -1221,6 +1221,7 @@ private Entry(EntryData data) { this.buildCompleted = data.isBuildCompleted(); this.cancelling = data.isCancelling(); this.cancelled = data.isCancelled(); + this.queueLeft = data.isQueueLeft(); this.customUrl = data.getCustomUrl(); this.unsuccessfulMessage = data.getUnsuccessfulMessage(); this.triggeredTimestamp = data.getTriggeredTimestamp(); @@ -1244,6 +1245,7 @@ public EntryData toEntryData() { data.setBuildCompleted(buildCompleted); data.setCancelling(cancelling); data.setCancelled(cancelled); + data.setQueueLeft(queueLeft); data.setCustomUrl(customUrl); data.setUnsuccessfulMessage(unsuccessfulMessage); data.setTriggeredTimestamp(triggeredTimestamp); diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/MemoryImprintTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/MemoryImprintTest.java index 076760acc..3aa1290d2 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/MemoryImprintTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/MemoryImprintTest.java @@ -197,6 +197,7 @@ public void testEntryDataRoundTrip() { data.setBuildCompleted(true); data.setCancelling(true); data.setCancelled(true); + data.setQueueLeft(true); data.setCustomUrl("http://example.test/custom"); data.setUnsuccessfulMessage("nope"); data.setTriggeredTimestamp(TRIGGERED_TS); @@ -210,6 +211,7 @@ public void testEntryDataRoundTrip() { assertTrue(roundTripped.isBuildCompleted()); assertTrue(roundTripped.isCancelling()); assertTrue(roundTripped.isCancelled()); + assertTrue(roundTripped.isQueueLeft()); assertEquals("http://example.test/custom", roundTripped.getCustomUrl()); assertEquals("nope", roundTripped.getUnsuccessfulMessage()); assertEquals(TRIGGERED_TS, roundTripped.getTriggeredTimestamp()); From 6e3f0416ea61366287495e26557f4fffb6e4a070 Mon Sep 17 00:00:00 2001 From: sboardwell Date: Mon, 20 Jul 2026 11:54:50 +0200 Subject: [PATCH 61/87] Add missing queueLeft --- .../plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index d8e3ffa2e..b7ca2add6 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -1221,6 +1221,7 @@ private Entry(EntryData data) { this.buildCompleted = data.isBuildCompleted(); this.cancelling = data.isCancelling(); this.cancelled = data.isCancelled(); + this.queueLeft = data.isQueueLeft(); this.customUrl = data.getCustomUrl(); this.unsuccessfulMessage = data.getUnsuccessfulMessage(); this.triggeredTimestamp = data.getTriggeredTimestamp(); From b2b97e16baa3813bc1054b5447c5df7c1a7deb66 Mon Sep 17 00:00:00 2001 From: sboardwell Date: Fri, 17 Jul 2026 16:36:52 +0200 Subject: [PATCH 62/87] Improve patchset cancellation logic to handle distributed event delivery correctly --- .../trigger/gerritnotifier/model/BuildMemory.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index b7ca2add6..3a0ec23de 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -489,7 +489,15 @@ private boolean shouldIgnoreEvent( && Integer.parseInt(runningChangeBasedEvent.getPatchSet().getNumber()) < Integer.parseInt(event.getPatchSet().getNumber()); - boolean shouldCancelPatchsetNumber = policy.isAbortNewPatchsets() || isOldPatch; + // When both events carry patchset numbers, the numeric comparison is authoritative: + // only cancel the running build if it is actually the older patchset. Falling back to + // policy.isAbortNewPatchsets() alone assumes "a new event just arrived" implies "it's + // the newest patchset" - true for single-JVM sequential event processing, but false in + // distributed/Hazelcast deployments where cross-replica event delivery can reorder + // patchset events, otherwise causing the newest patchset's build to be wrongly cancelled + // by an older, late-arriving one. Without patchset numbers (e.g. topic-changed events), + // fall back to the policy flag as before. + boolean shouldCancelPatchsetNumber = hasPatchNumbers ? isOldPatch : policy.isAbortNewPatchsets(); boolean isAbortAbandonedPatchset = policy.isAbortAbandonedPatchsets() && (event instanceof ChangeAbandoned); From 40f747a51a72bab90992959e8dad41a099b43303 Mon Sep 17 00:00:00 2001 From: sboardwell Date: Sat, 18 Jul 2026 17:53:06 +0200 Subject: [PATCH 63/87] Enhance cancellation logic to handle ambiguous queue-item states and prevent premature finalization of builds --- .../HazelcastBuildMemoryStorage.java | 130 +++++++++++++++++- .../gerritnotifier/model/BuildMemory.java | 99 +++++++++++++ 2 files changed, 222 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index e0c6b61ea..35f8200e9 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -30,11 +30,13 @@ import com.hazelcast.map.IMap; import com.hazelcast.map.listener.EntryAddedListener; import com.sonyericsson.hudson.plugins.gerrit.trigger.diagnostics.BuildMemoryReport; +import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.ToGerritRunListener; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildMemory.MemoryImprint; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.BuildsStartedStats; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.EntryData; import com.sonyericsson.hudson.plugins.gerrit.trigger.gerritnotifier.model.MemoryImprintData; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.AbandonedPatchsetInterruption; +import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.GerritCause; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.NewPatchSetInterruption; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.BuildMemoryStorage; import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; @@ -43,6 +45,7 @@ import hudson.model.Job; import hudson.model.Result; import hudson.model.Run; +import hudson.model.TaskListener; import hudson.security.ACL; import jenkins.model.CauseOfInterruption; import hudson.security.ACLContext; @@ -152,6 +155,24 @@ public class HazelcastBuildMemoryStorage extends BuildMemoryStorage { */ private static final long DEFERRED_ABORT_DELAY_SECONDS = 3L; + /** + * Grace period in seconds before treating an ambiguous queue-item cancellation as final. + *

                      + * {@link HazelcastQueueCancellationStrategy#isLoadBalancedCancellation} cannot reliably tell + * a genuine Gerrit-triggered cancellation apart from a benign cross-replica queue-item + * relocation - Jenkins preserves no marker on {@code LeftItem} either way (see that method's + * own doc comment). Rather than finalize immediately whenever {@code isCancelling} happens to + * already be set, {@link #cancelled} waits this long for a possible {@link #started} call to + * arrive from whichever replica the item actually landed on before concluding the build was + * genuinely, permanently cancelled. + *

                      + * This narrows the race rather than closing it: observed real-world delay between a + * cancellation decision and the relocated build's own started() call has been as long as ~6s + * in production logs, so this is set comfortably above that - but a sufficiently slow + * relocation can still, in principle, outlast this window. + */ + private static final long CANCEL_FINALIZE_GRACE_SECONDS = 10L; + /** * Poll interval in milliseconds used by {@link #handleAbortRequest} while waiting for a * Pipeline build's CPS execution to start before delivering the interrupt. @@ -680,12 +701,14 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull String projectFullName = project.getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). - long cancelledTimestamp = System.currentTimeMillis(); if (!tryLockWithTimeout(map, key)) { logger.error("Could not acquire distributed lock for key {} within {}s - skipping cancelled()", key, LOCK_ACQUIRE_TIMEOUT_SECONDS); return; } + // Set when this queue exit is ambiguous (isCancelling was already true) and needs a + // deferred re-check after the lock is released - see CANCEL_FINALIZE_GRACE_SECONDS. + boolean scheduleFinalizeCheck = false; try { MemoryImprintData data = map.get(key); if (data == null) { @@ -699,12 +722,19 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull found = true; if (!entryData.isBuildCompleted()) { if (entryData.isCancelling()) { - // Gerrit-triggered cancellation: the intent was set via setCancelling() - // first (e.g. by cancelOutdatedBuilds). This is a real, deliberate cancel. - entryData.setCancelled(true); - entryData.setCancelling(false); - entryData.setCompletedTimestamp(cancelledTimestamp); - entryData.setBuildCompleted(true); + // Ambiguous: the intent was set via setCancelling() first (e.g. by + // cancelOutdatedBuilds), but this queue exit itself could equally be + // a genuine Gerrit-triggered cancellation OR a benign cross-replica + // queue-item relocation - isLoadBalancedCancellation() can't tell + // them apart (see its own doc comment; Jenkins preserves no marker + // on LeftItem either way). Don't finalize yet: mark queueLeft (not + // completed) and leave isCancelling=true so started()'s own + // deferred-abort compensator still fires correctly if this really is + // a relocation and the build reports in from wherever it landed. + // scheduleDeferredCancelFinalize (below, after the lock is released) + // decides the real outcome after a grace period. + entryData.setQueueLeft(true); + scheduleFinalizeCheck = true; } else if (entryData.getBuildId() == null) { // No prior cancellation intent AND build has not started anywhere. // This is a potential load-balanced queue move (build will restart @@ -744,6 +774,92 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull } finally { map.unlock(key); } + + if (scheduleFinalizeCheck) { + scheduleDeferredCancelFinalize(event, project, key, projectFullName); + } + } + + /** + * Re-checks a provisionally-cancelled entry after {@link #CANCEL_FINALIZE_GRACE_SECONDS} and + * finalizes it as a genuine cancellation only if no {@link #started} call arrived for it in + * the meantime. + *

                      + * Exists because {@link HazelcastQueueCancellationStrategy#isLoadBalancedCancellation} cannot + * distinguish a genuine Gerrit-triggered cancellation from a benign cross-replica queue-item + * relocation - without this grace period, a relocated-but-still-alive build was getting marked + * completed/forgotten (triggering premature Gerrit "No Builds Executed" feedback) before it + * had a chance to call {@code started()} on whichever replica it landed on, silently skipping + * the deferred cross-replica abort this class already schedules for the reverse race (see + * {@link #started}). + *

                      + * Deliberately narrows the race rather than closing it - see {@link #CANCEL_FINALIZE_GRACE_SECONDS}. + * + * @param event the event whose entry may need finalizing + * @param project the project/job the entry is for + * @param key the distributed map key for event + * @param projectFullName project's full name + */ + private void scheduleDeferredCancelFinalize( + @NonNull GerritTriggeredEvent event, @NonNull Job project, String key, String projectFullName) { + IMap map = getDistributedMemory(); + if (map == null) { + return; + } + Timer.get().schedule(() -> { + if (!tryLockWithTimeout(map, key)) { + logger.error("Could not acquire distributed lock for key {} within {}s - skipping " + + "deferred cancel-finalize check", key, LOCK_ACQUIRE_TIMEOUT_SECONDS); + return; + } + boolean finalized = false; + try { + MemoryImprintData data = map.get(key); + if (data == null || data.getEntries() == null) { + return; + } + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + if (entryData.isCancelling() && !entryData.isBuildCompleted() + && entryData.getBuildId() == null) { + logger.info("Finalizing deferred cancellation for project={} event={}: " + + "no started() call arrived within {}s grace period - genuine cancel.", + projectFullName, key, CANCEL_FINALIZE_GRACE_SECONDS); + entryData.setCancelled(true); + entryData.setCancelling(false); + entryData.setCompletedTimestamp(System.currentTimeMillis()); + entryData.setBuildCompleted(true); + map.put(key, data); + finalized = true; + } else { + logger.debug("Deferred cancel-finalize check for project={} event={}: " + + "buildId={} isCancelling={} isBuildCompleted={} - a build " + + "reported in elsewhere or already completed; treating as a " + + "relocation, not a genuine cancellation.", + projectFullName, key, entryData.getBuildId(), entryData.isCancelling(), + entryData.isBuildCompleted()); + } + break; + } + } + } catch (Exception e) { + logger.error("Failed deferred cancel-finalize check: project={}, event={}", projectFullName, key, e); + return; + } finally { + map.unlock(key); + } + if (finalized) { + // The original GerritQueueListener#onLeft call that led here checked + // isAllBuildsCompleted() synchronously, before this deferred finalize ran, so it + // saw "not yet complete" and skipped the Gerrit-feedback/forget step. Re-invoke it + // now that the entry is actually finalized, exactly as it would have run + // synchronously had this been unambiguous from the start. + ToGerritRunListener runListener = ToGerritRunListener.getInstance(); + if (runListener != null) { + runListener.allBuildsCompleted(event, new GerritCause(event, false), TaskListener.NULL); + } + } + }, CANCEL_FINALIZE_GRACE_SECONDS, TimeUnit.SECONDS); } @Override diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index 3a0ec23de..4dfe1cd5a 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -353,6 +353,12 @@ public void cancelOutdatedEvents( List outdatedEvents = new ArrayList<>(); CauseOfInterruption cause = new NewPatchSetInterruption(); + // True if newEvent is itself already outdated relative to some already-registered + // event that's a strictly newer patchset of the same change and still has an active + // build for this job. Set inside the loop below - see its own comment at the + // shouldIgnoreEvent call for why this direction needs a separate check. + boolean newEventIsOutdated = false; + synchronized (storage) { Map allEvents = storage.getAllEvents(); logger.info("BuildMemory has {} events in memory", allEvents.size()); @@ -380,6 +386,20 @@ public void cancelOutdatedEvents( } if (shouldIgnoreEvent(newEvent, policy, runningChangeBasedEvent, trigger)) { + // shouldIgnoreEvent can return true because runningChangeBasedEvent is + // actually the newer patchset (see its own isOldPatch check) - i.e. + // newEvent itself is the outdated one, not the other way around. This + // loop only ever asks "should I cancel the OTHER, already-registered + // event?" - it never asks the reverse. Left unguarded, a late-arriving + // older patchset's own build runs to completion fully unsuppressed + // whenever cross-replica event delivery reorders patchset arrival + // (confirmed to happen on mc3 - see the HZ-104 cross-node cancellation + // race writeup). Detect that case here so newEvent gets cancelled too, + // via the same isCancelling + deferred-abort machinery already used for + // the normal direction. + if (isNewEventOutdatedByRunningEvent(newEvent, runningChangeBasedEvent, jobName, entry.getValue())) { + newEventIsOutdated = true; + } logger.debug("Ignoring event based on policy"); continue; } @@ -431,6 +451,19 @@ public void cancelOutdatedEvents( // Add event so it can be found and cancelled by future events // This is critical for silent mode where onTriggered() isn't called triggered(newEvent, job); + + // newEvent turned out to be outdated relative to an already-active newer + // patchset for this exact job (see the loop above) - cancel it too, now + // that it's registered, via the same post-loop cancelMatchingJobs path + // used for the normal direction, so started()'s existing deferred-abort + // compensator can still catch it if its own build has already started or + // starts shortly on some replica. + if (newEventIsOutdated) { + logger.info("New event {} is itself outdated relative to an already-active " + + "newer patchset for job {} - cancelling it too", newEvent, jobName); + storage.setCancelling(newEvent, job); + outdatedEvents.add(newEvent); + } } } } @@ -509,6 +542,72 @@ private boolean shouldIgnoreEvent( return false; } + /** + * True if {@code runningEvent} is a strictly newer patchset of the same change as + * {@code newEvent} and still has an active (non-completed, non-cancelling, non-cancelled, + * non-queueLeft) build entry for {@code jobName} - i.e. {@code newEvent} itself is the + * outdated one here, not {@code runningEvent}. + *

                      + * This is the mirror image of the {@code isOldPatch} check in {@link #shouldIgnoreEvent}. + * That check only ever decides whether {@code runningEvent} should be cancelled by + * {@code newEvent} - it correctly refuses to do so when {@code runningEvent} is actually + * newer, but nothing then cancels {@code newEvent} itself in that case. Cross-replica event + * delivery can deliver a newer patchset's event to some replica before an older one reaches + * any replica at all (confirmed on {@code mc3} - see the HZ-104 cross-node cancellation race + * writeup), which is exactly when this matters: without this check, the late-arriving, + * actually-outdated {@code newEvent} would never recognize itself as such and would run to + * completion alongside the newer patchset that's already building. + *

                      + * Independently re-checks the same-change condition ({@link #shouldIgnoreEvent} can return + * {@code true} for several unrelated reasons - topic mismatch, different change, manual + * patchset policy - so its return value alone doesn't confirm this is a patchset-order + * situation on the same change). + * + * @param newEvent the event that just arrived + * @param runningEvent an already-registered event to compare against + * @param jobName the job to check for an active build + * @param imprint runningEvent's current memory imprint + * @return true if newEvent should be treated as already outdated relative to runningEvent + */ + private boolean isNewEventOutdatedByRunningEvent( + ChangeBasedEvent newEvent, ChangeBasedEvent runningEvent, String jobName, MemoryImprint imprint) { + + Change change = runningEvent.getChange(); + Change newChange = newEvent.getChange(); + if (change == null || !change.equals(newChange)) { + return false; + } + + if (newEvent.getPatchSet() == null || runningEvent.getPatchSet() == null + || newEvent.getPatchSet().getNumber() == null || runningEvent.getPatchSet().getNumber() == null) { + return false; + } + + int newEventNum; + int runningEventNum; + try { + newEventNum = Integer.parseInt(newEvent.getPatchSet().getNumber()); + runningEventNum = Integer.parseInt(runningEvent.getPatchSet().getNumber()); + } catch (NumberFormatException e) { + return false; + } + + if (runningEventNum <= newEventNum) { + return false; + } + + for (Entry imprintEntry : imprint.getEntries()) { + if (imprintEntry.isProject(jobName) + && !imprintEntry.isBuildCompleted() + && !imprintEntry.isCancelling() + && !imprintEntry.isCancelled() + && !imprintEntry.isQueueLeft()) { + return true; + } + } + return false; + } + /** * Cancels any jobs that were triggered by the given event. * Ported from RunningJobs.cancelMatchingJobs(). From 5fca872fa7d08b6b134c6ae7d2eb3bbca61383b1 Mon Sep 17 00:00:00 2001 From: sboardwell Date: Mon, 20 Jul 2026 16:01:44 +0200 Subject: [PATCH 64/87] Stop gating cancellation-eligibility on queueLeft queueLeft means "left the queue for an ambiguous reason (possibly a load-balanced relocation to another mc3 replica), not yet confirmed as a genuine cancel" - it is not proof an entry is done. Three call sites (BuildMemory#cancelOutdatedEvents, BuildMemory#isNewEventOutdatedByRunningEvent, HazelcastBuildMemoryStorage#setCancelling) were treating it as such, which let a relocated-but-not-yet-restarted entry dodge cancellation entirely whenever a newer patchset arrived during the relocation window - the mc3 HZ-006/HZ-104 race. isBuilding()'s existing queueLeft gates are untouched since they answer a different question (executor occupancy for reporting) tied to HZ-004. Not yet deployed/tested against a live mc3 run. --- .../hazelcast/HazelcastBuildMemoryStorage.java | 7 ++++++- .../trigger/gerritnotifier/model/BuildMemory.java | 15 +++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 35f8200e9..d2a8ff294 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -885,8 +885,13 @@ public synchronized void setCancelling(@NonNull GerritTriggeredEvent event, @Non boolean updated = false; for (EntryData entryData : data.getEntries()) { if (projectFullName.equals(entryData.getProjectFullName())) { + // Not gated on !isQueueLeft(): an ambiguous queue exit (possible + // load-balanced relocation) is not proof this entry is done, and must + // remain eligible to be marked cancelling so a relocated-then-started + // build still gets cross-replica-aborted (see BuildMemory's identical + // reasoning in cancelOutdatedEvents()). if (!entryData.isBuildCompleted() && !entryData.isCancelling() - && !entryData.isCancelled() && !entryData.isQueueLeft()) { + && !entryData.isCancelled()) { entryData.setCancelling(true); updated = true; } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index 948c1ad15..c8a062aca 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -412,11 +412,17 @@ public void cancelOutdatedEvents( logger.debug("Checking entry: project={}, completed={}, cancelling={}, cancelled={}", imprintEntry.getProject(), imprintEntry.isBuildCompleted(), imprintEntry.isCancelling(), imprintEntry.isCancelled()); + // Deliberately does NOT check !imprintEntry.isQueueLeft(): queueLeft means + // "left the queue for an ambiguous reason (possibly a load-balanced + // relocation to another mc3 replica), not yet confirmed as a genuine + // cancel" - it is not itself proof the entry is done. Excluding it here + // let relocated-but-not-yet-restarted entries dodge cancellation entirely + // whenever a newer patchset arrived during the relocation window (the + // HZ-006/HZ-104 mc3 race). if (imprintEntry.isProject(jobName) && !imprintEntry.isBuildCompleted() && !imprintEntry.isCancelling() - && !imprintEntry.isCancelled() - && !imprintEntry.isQueueLeft()) { + && !imprintEntry.isCancelled()) { hasActiveBuildsForJob = true; logger.debug("Found active build for job {}", jobName); break; @@ -597,11 +603,12 @@ private boolean isNewEventOutdatedByRunningEvent( } for (Entry imprintEntry : imprint.getEntries()) { + // See the identical comment in cancelOutdatedEvents() above: queueLeft is not + // proof this entry is done, so it must not exclude it from being "active". if (imprintEntry.isProject(jobName) && !imprintEntry.isBuildCompleted() && !imprintEntry.isCancelling() - && !imprintEntry.isCancelled() - && !imprintEntry.isQueueLeft()) { + && !imprintEntry.isCancelled()) { return true; } } From f4bc46a11c6a74a553afc6e2c133fa27726209d2 Mon Sep 17 00:00:00 2001 From: sboardwell Date: Mon, 20 Jul 2026 16:23:06 +0200 Subject: [PATCH 65/87] Log the previously-silent queueLeft-via-relocation branch cancelled()'s "no prior cancellation intent, no buildId yet" branch (the one this session's cancellation-eligibility fix targets) set queueLeft=true with no logging at all, making it impossible to tell from pod-logs.log whether a given mc3 test run actually exercised the race the fix addresses, or simply didn't hit the relocation window. Needed to attribute PASS/FAIL results to the fix rather than to luck. --- .../coordination/hazelcast/HazelcastBuildMemoryStorage.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index d2a8ff294..0ce168c81 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -745,6 +745,9 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull // instance — leave the entry completely untouched so it stays visible // to cancelOutdatedBuilds on that instance. entryData.setQueueLeft(true); + logger.info("Marking queueLeft (pre-cancellation-intent queue exit, no " + + "buildId yet) for project={} event={} - possible load-balanced " + + "relocation with no started() call yet", projectFullName, key); } else { logger.debug("cancelled() called after started() for project={} event={}: " + "build already running (buildId={}), ignoring late onLeft.", From 1d9a0d6d820bed000ef0ca8d1c48600860762403 Mon Sep 17 00:00:00 2001 From: sboardwell Date: Tue, 21 Jul 2026 10:25:15 +0200 Subject: [PATCH 66/87] Implement patchset order verification for distributed storage mode --- .../HazelcastBuildMemoryStorage.java | 8 +++++++ .../gerritnotifier/model/BuildMemory.java | 21 +++++++++-------- .../trigger/spi/BuildMemoryStorage.java | 23 +++++++++++++++++++ 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 0ce168c81..2db209ec1 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -1276,4 +1276,12 @@ public boolean eventsMatch(@NonNull GerritTriggeredEvent event1, @NonNull Gerrit String id2 = EventIdentifier.generateEventId(event2); return id1.equals(id2); } + + @Override + public boolean requiresPatchsetOrderVerification() { + // Cross-replica event delivery can reorder patchset arrival (the HZ-104 mc3 race) - + // numeric patchset order must override abortNewPatchsets once both events carry + // patchset numbers, rather than trusting that "arrived later" means "is newer". + return true; + } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index c8a062aca..3754baab4 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -528,15 +528,18 @@ private boolean shouldIgnoreEvent( && Integer.parseInt(runningChangeBasedEvent.getPatchSet().getNumber()) < Integer.parseInt(event.getPatchSet().getNumber()); - // When both events carry patchset numbers, the numeric comparison is authoritative: - // only cancel the running build if it is actually the older patchset. Falling back to - // policy.isAbortNewPatchsets() alone assumes "a new event just arrived" implies "it's - // the newest patchset" - true for single-JVM sequential event processing, but false in - // distributed/Hazelcast deployments where cross-replica event delivery can reorder - // patchset events, otherwise causing the newest patchset's build to be wrongly cancelled - // by an older, late-arriving one. Without patchset numbers (e.g. topic-changed events), - // fall back to the policy flag as before. - boolean shouldCancelPatchsetNumber = hasPatchNumbers ? isOldPatch : policy.isAbortNewPatchsets(); + // storage.requiresPatchsetOrderVerification() is false for local mode (default): + // events are processed sequentially in a single JVM, so arrival order can be + // trusted, and abortNewPatchsets means what it says - cancel the running build on + // any subsequent patchset event, regardless of number. It's true only for storage + // modes where cross-replica event delivery can reorder patchset arrival (Hazelcast); + // there, once both events carry patchset numbers, the numeric comparison must be + // authoritative instead, or a late/reordered older-patchset event can wrongly cancel + // an already-running newer build (the HZ-104 mc3 race). See + // BuildMemoryStorage#requiresPatchsetOrderVerification for the full rationale. + boolean shouldCancelPatchsetNumber = (hasPatchNumbers && storage.requiresPatchsetOrderVerification()) + ? isOldPatch + : policy.isAbortNewPatchsets() || isOldPatch; boolean isAbortAbandonedPatchset = policy.isAbortAbandonedPatchsets() && (event instanceof ChangeAbandoned); diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java index c79a29d3d..43ad52669 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java @@ -338,4 +338,27 @@ public void requestCrossReplicaAbort(@NonNull GerritTriggeredEvent event, @NonNu */ public abstract boolean eventsMatch(@NonNull GerritTriggeredEvent event1, @NonNull GerritTriggeredEvent event2); + + /** + * Whether this storage mode requires numeric patchset-order verification before trusting + * that a newly-arrived event is actually newer than an already-running one. + *

                      + * Local mode (default, returns {@code false}): events are processed + * sequentially in a single JVM, so arrival order can be trusted. The + * {@code abortNewPatchsets} policy means exactly what it says: cancel the running build on + * any subsequent patchset event for the same change, regardless of patchset number. + *

                      + * Distributed mode (e.g. Hazelcast, returns {@code true}): cross-replica + * event delivery can reorder patchset arrival, so a late-arriving event with a lower + * patchset number does not necessarily mean it's older news - it can simply mean it took a + * slower path to this replica. Once both events carry patchset numbers, the numeric + * comparison must be treated as authoritative instead of arrival order, or a late/reordered + * older-patchset event can wrongly cancel an already-running newer build. + * + * @return true if numeric patchset order should override {@code abortNewPatchsets} once both + * events carry patchset numbers, false to trust arrival order as before + */ + public boolean requiresPatchsetOrderVerification() { + return false; + } } From 5e676a0bea22d8da53f661911a6a42ac836ba4e8 Mon Sep 17 00:00:00 2001 From: Stephan F Date: Tue, 21 Jul 2026 12:58:03 +0100 Subject: [PATCH 67/87] Add disclaimers to readme --- docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md | 26 +++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md b/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md index 7e5fe90ef..afaa6cea3 100644 --- a/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md +++ b/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md @@ -1,12 +1,12 @@ # Distributed Event Management support -The plugin supports Distributed Event Management support where two or more Jenkins instance -run in parallel (sharing the gerrit memory of the plugin). When enabled, a Hazelcast +The plugin supports Distributed Event Management support where two or more replicas of a Jenkins(*) instance +run in parallel (sharing the Gerrit memory of the plugin). When enabled, a Hazelcast cluster coordinates the instances so that: - Each Gerrit event is processed by **exactly one** instance (event claiming) - Build state is shared across instances (distributed build memory) -- Gerrit feedback (votes and comments) is sent **exactly once** per build event +- Gerrit feedback (votes and comments) are sent **exactly once** per build event By default, the plugin runs in **local mode** and requires no additional configuration. Local mode is fully backward-compatible with single-instance Jenkins deployments. @@ -17,7 +17,7 @@ Alternative coordination backends can be implemented by extending notification-claiming strategies for a given coordination mode. A higher `@Extension` ordinal takes precedence over the built-in Hazelcast provider. -## Hazlecast implementation +## Hazelcast implementation Hazelcast mode is activated via a JVM system property. Jenkins connects as a lightweight client to a Hazelcast sidecar container, reusing the cross-pod cluster the sidecar @@ -85,3 +85,21 @@ rules: resources: ["endpointslices"] verbs: ["get", "list"] ``` + +#### Kubernetes — Client Mode with Separate Hazelcast Cluster + +For larger deployments or strict separation of concerns, you can decouple the coordination layer by running a standalone Hazelcast cluster. Jenkins still connects as a lightweight client, but routes traffic to the separate cluster via a Kubernetes service instead of a sidecar. + +Add the following JVM arguments to the Jenkins instance, updating the client address to point to your standalone Hazelcast Kubernetes service (replace hazelcast-service.default.svc.cluster.local with your actual service DNS and namespace): + + -Dgerrit.trigger.coordination.mode=hazelcast + -Dgerrit.trigger.coordination.hazelcast.client.addresses=hazelcast-service.default.svc.cluster.local:5702 + -Dgerrit.trigger.coordination.hazelcast.client.cluster.name=gerrit-trigger-cluster + +In this topology: +- You do not need to add the sidecar container to the Jenkins pod spec. +- The Jenkins service account does not need RBAC permissions for peer discovery, as cluster management is handled entirely by the standalone Hazelcast nodes. +- You must deploy and manage the Hazelcast cluster independently (e.g., via the official Hazelcast Helm chart), ensuring you configure it to match your expected `HZ_CLUSTERNAME` and port (`5702`). + +(*) Jenkins does not support multiple replicas for a single logical instance, this feature is not tested with Jenkins. This feature is provided for CloudBees CI (Enterprise Jenkins). +This feature is provided as a community effort and is not endorsed or officially supported by CloudBees. From 96f17bd16710b80a34bd9a903826219d2955e9ea Mon Sep 17 00:00:00 2001 From: Stephan F Date: Tue, 21 Jul 2026 12:58:35 +0100 Subject: [PATCH 68/87] Update email address --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 5a8f14fa6..0c54ce41c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ Each job can be configured with one Gerrit server. ## Maintainers * Robert Sandell - - robert.sandell@cloudbees.com - sandell.robert@gmail.com * Tomas Westling From 93ea92508c556ff3b791980c5a46ea70612389a1 Mon Sep 17 00:00:00 2001 From: Stephan F Date: Tue, 21 Jul 2026 13:55:25 +0100 Subject: [PATCH 69/87] Cluster name must be different per logical instance --- docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md b/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md index afaa6cea3..f488cabca 100644 --- a/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md +++ b/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md @@ -35,6 +35,8 @@ All distributed storage settings are controlled by JVM system properties passed Port `5702` is used by default to avoid potential conflicts with other Hazelcast cluster, which could occupy port `5701`. +Cluster name must be different for each logical instance. Multiple replicas of a single instance may connect to the same cluster name. + ### Configuration Example #### Kubernetes — Client Mode with Hazelcast Sidecar @@ -99,7 +101,7 @@ Add the following JVM arguments to the Jenkins instance, updating the client add In this topology: - You do not need to add the sidecar container to the Jenkins pod spec. - The Jenkins service account does not need RBAC permissions for peer discovery, as cluster management is handled entirely by the standalone Hazelcast nodes. -- You must deploy and manage the Hazelcast cluster independently (e.g., via the official Hazelcast Helm chart), ensuring you configure it to match your expected `HZ_CLUSTERNAME` and port (`5702`). +- You must deploy and manage the Hazelcast cluster independently (e.g. via the official Hazelcast Helm chart), ensuring you configure it to match your expected `HZ_CLUSTERNAME` and port (`5702`). (*) Jenkins does not support multiple replicas for a single logical instance, this feature is not tested with Jenkins. This feature is provided for CloudBees CI (Enterprise Jenkins). This feature is provided as a community effort and is not endorsed or officially supported by CloudBees. From 37dffa4ecea2d2b47755c3cf30983ae45ca239c9 Mon Sep 17 00:00:00 2001 From: sboardwell Date: Wed, 22 Jul 2026 15:36:16 +0200 Subject: [PATCH 70/87] Remove patchset order verification logic and related comments for local storage mode --- .../HazelcastBuildMemoryStorage.java | 8 ------- .../gerritnotifier/model/BuildMemory.java | 13 +---------- .../trigger/spi/BuildMemoryStorage.java | 23 ------------------- 3 files changed, 1 insertion(+), 43 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 2db209ec1..0ce168c81 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -1276,12 +1276,4 @@ public boolean eventsMatch(@NonNull GerritTriggeredEvent event1, @NonNull Gerrit String id2 = EventIdentifier.generateEventId(event2); return id1.equals(id2); } - - @Override - public boolean requiresPatchsetOrderVerification() { - // Cross-replica event delivery can reorder patchset arrival (the HZ-104 mc3 race) - - // numeric patchset order must override abortNewPatchsets once both events carry - // patchset numbers, rather than trusting that "arrived later" means "is newer". - return true; - } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index 3754baab4..b4199ace0 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -528,18 +528,7 @@ private boolean shouldIgnoreEvent( && Integer.parseInt(runningChangeBasedEvent.getPatchSet().getNumber()) < Integer.parseInt(event.getPatchSet().getNumber()); - // storage.requiresPatchsetOrderVerification() is false for local mode (default): - // events are processed sequentially in a single JVM, so arrival order can be - // trusted, and abortNewPatchsets means what it says - cancel the running build on - // any subsequent patchset event, regardless of number. It's true only for storage - // modes where cross-replica event delivery can reorder patchset arrival (Hazelcast); - // there, once both events carry patchset numbers, the numeric comparison must be - // authoritative instead, or a late/reordered older-patchset event can wrongly cancel - // an already-running newer build (the HZ-104 mc3 race). See - // BuildMemoryStorage#requiresPatchsetOrderVerification for the full rationale. - boolean shouldCancelPatchsetNumber = (hasPatchNumbers && storage.requiresPatchsetOrderVerification()) - ? isOldPatch - : policy.isAbortNewPatchsets() || isOldPatch; + boolean shouldCancelPatchsetNumber = policy.isAbortNewPatchsets() || isOldPatch; boolean isAbortAbandonedPatchset = policy.isAbortAbandonedPatchsets() && (event instanceof ChangeAbandoned); diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java index 43ad52669..c79a29d3d 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java @@ -338,27 +338,4 @@ public void requestCrossReplicaAbort(@NonNull GerritTriggeredEvent event, @NonNu */ public abstract boolean eventsMatch(@NonNull GerritTriggeredEvent event1, @NonNull GerritTriggeredEvent event2); - - /** - * Whether this storage mode requires numeric patchset-order verification before trusting - * that a newly-arrived event is actually newer than an already-running one. - *

                      - * Local mode (default, returns {@code false}): events are processed - * sequentially in a single JVM, so arrival order can be trusted. The - * {@code abortNewPatchsets} policy means exactly what it says: cancel the running build on - * any subsequent patchset event for the same change, regardless of patchset number. - *

                      - * Distributed mode (e.g. Hazelcast, returns {@code true}): cross-replica - * event delivery can reorder patchset arrival, so a late-arriving event with a lower - * patchset number does not necessarily mean it's older news - it can simply mean it took a - * slower path to this replica. Once both events carry patchset numbers, the numeric - * comparison must be treated as authoritative instead of arrival order, or a late/reordered - * older-patchset event can wrongly cancel an already-running newer build. - * - * @return true if numeric patchset order should override {@code abortNewPatchsets} once both - * events carry patchset numbers, false to trust arrival order as before - */ - public boolean requiresPatchsetOrderVerification() { - return false; - } } From 4c7cf4efd53d498d2fc147cc8201df0d3ea74a78 Mon Sep 17 00:00:00 2001 From: sboardwell Date: Wed, 22 Jul 2026 15:36:25 +0200 Subject: [PATCH 71/87] Stop Gerrit server connection in tearDown method --- .../plugins/gerrit/trigger/spec/SpecGerritTriggerHudsonTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/SpecGerritTriggerHudsonTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/SpecGerritTriggerHudsonTest.java index 46692d9ff..c97c2fde4 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/SpecGerritTriggerHudsonTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/SpecGerritTriggerHudsonTest.java @@ -173,6 +173,7 @@ private void clearHazelcastMaps() { */ @After public void tearDown() throws Exception { + gerritServer.stopConnection(); serverMock.stopServer(sshd); sshd = null; HazelcastTestHelper.clearAllMaps(); From dc90b71a8383001eae2181ee9bb31208729bcbb0 Mon Sep 17 00:00:00 2001 From: sboardwell Date: Wed, 22 Jul 2026 16:34:00 +0200 Subject: [PATCH 72/87] Refactor Hazelcast coordination logic to ensure proper cluster formation before connecting to Gerrit --- .../plugins/gerrit/trigger/PluginImpl.java | 82 ----------------- .../HazelcastCoordinationProvider.java | 91 +++++++++++++++++++ 2 files changed, 91 insertions(+), 82 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java index 380125384..9d5292bba 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/PluginImpl.java @@ -71,7 +71,6 @@ import java.util.LinkedList; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.TimeUnit; import jenkins.model.Jenkins; @@ -144,28 +143,6 @@ public class PluginImpl extends GlobalConfiguration { */ public static final String TEST_SSH_KEYFILE_LOCATION_PROPERTY = PluginImpl.class.getName() + "_test_ssh_key_file"; - /** - * System property: minimum number of Hazelcast cluster members expected before connecting to Gerrit. - * Default 1 disables the wait (single-instance or local mode). - * Set to 2 or more in distributed installations to prevent the startup race where events arrive before - * the distributed claim map is shared across replicas. - */ - public static final String HAZELCAST_EXPECTED_MEMBERS_PROPERTY = - "gerrit.trigger.coordination.hazelcast.expected.members"; - - /** - * System property: maximum seconds to wait for Hazelcast cluster formation. - * Default: 30 seconds. - */ - public static final String HAZELCAST_CLUSTER_WAIT_TIMEOUT_PROPERTY = - "gerrit.trigger.coordination.hazelcast.cluster.wait.timeout.seconds"; - - private static final int DEFAULT_EXPECTED_CLUSTER_MEMBERS = 1; - - private static final int DEFAULT_CLUSTER_WAIT_TIMEOUT_SECONDS = 30; - - private static final long CLUSTER_WAIT_POLL_INTERVAL_MS = 500L; - /** * Gets api. * @return the api. @@ -621,11 +598,6 @@ public void start() { // background event-processing thread. CoordinationModeFactory.get().getStorage(); - // Wait for Hazelcast cluster to reach the expected member count before connecting to Gerrit. - // Without this, events received during the startup window bypass the distributed claim mechanism - // and cause duplicate builds across replicas. - waitForHazelcastCluster(); - GerritSendCommandQueue.initialize(pluginConfig); gerritEventManager = new JenkinsAwareGerritHandler(pluginConfig.getNumberOfReceivingWorkerThreads()); for (GerritServer s : servers) { @@ -634,60 +606,6 @@ public void start() { active = true; } - /** - * Waits for the Hazelcast cluster to reach the expected number of members before - * Gerrit server connections are opened. - *

                      - * In distributed scenarios each replica has its own SSH connection to Gerrit and therefore - * receives every event independently. Without this guard, a replica that starts while - * the Hazelcast cluster is still forming will process events against its own single-member - * IMap, making the distributed claim invisible to other replicas and causing duplicate builds. - *

                      - * The wait is skipped when Hazelcast is not active (local mode) or when - * {@link #HAZELCAST_EXPECTED_MEMBERS_PROPERTY} is 1 (the default). - */ - private void waitForHazelcastCluster() { - com.hazelcast.core.HazelcastInstance hz = - com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.HazelcastInstanceProvider - .getInstance(); - if (hz == null) { - return; - } - - int expectedMembers = Integer.getInteger(HAZELCAST_EXPECTED_MEMBERS_PROPERTY, - DEFAULT_EXPECTED_CLUSTER_MEMBERS); - if (expectedMembers <= DEFAULT_EXPECTED_CLUSTER_MEMBERS) { - return; - } - - int timeoutSeconds = Integer.getInteger(HAZELCAST_CLUSTER_WAIT_TIMEOUT_PROPERTY, - DEFAULT_CLUSTER_WAIT_TIMEOUT_SECONDS); - logger.info("Waiting for Hazelcast cluster to form ({} expected members, timeout: {}s)...", - expectedMembers, timeoutSeconds); - - long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(timeoutSeconds); - int currentSize = hz.getCluster().getMembers().size(); - while (currentSize < expectedMembers && System.currentTimeMillis() < deadline) { - logger.debug("Hazelcast cluster has {} of {} expected members, waiting...", - currentSize, expectedMembers); - try { - Thread.sleep(CLUSTER_WAIT_POLL_INTERVAL_MS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - logger.warn("Interrupted while waiting for Hazelcast cluster formation"); - return; - } - currentSize = hz.getCluster().getMembers().size(); - } - - if (currentSize >= expectedMembers) { - logger.info("Hazelcast cluster ready: {} member(s)", currentSize); - } else { - logger.warn("Timed out waiting for Hazelcast cluster ({}/{} members). " - + "Proceeding anyway - duplicate builds may occur.", currentSize, expectedMembers); - } - } - /** * Initialize the active coordination mode provider. *

                      diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java index 01dced9d7..e81005ce5 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationProvider.java @@ -31,6 +31,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.concurrent.TimeUnit; + /** * Coordination provider for Hazelcast distributed mode. *

                      @@ -76,6 +78,39 @@ public class HazelcastCoordinationProvider extends CoordinationModeProvider { */ private static final String HAZELCAST_MODE = "hazelcast"; + /** + * System property: minimum number of Hazelcast cluster members expected before connecting to Gerrit. + * Default 1 disables the wait (single-instance or local mode). + *

                      + * Only relevant for an externally-managed Hazelcast cluster whose member + * discovery/formation is decoupled from this Jenkins replica's own startup - e.g. a shared + * cluster whose membership can still be changing (scaling, rebalancing, network delays) + * independently of when this replica boots. In that topology, formation time isn't bounded + * by anything Jenkins controls, so it can plausibly exceed Jenkins' own (slow) startup time. + *

                      + * Does not apply to a per-replica Hazelcast sidecar (one server co-located + * with each Jenkins pod, joining only its Jenkins-managed peers): there, sidecar formation + * and Jenkins startup share the same pod lifecycle, and in practice Jenkins' own boot time + * (JVM start, CasC, plugin/extension loading - tens of seconds) dwarfs sidecar discovery time + * (single-digit seconds even from a cold multi-pod restart), so the client always observes + * the fully-formed cluster on its first connection regardless of this setting. + */ + public static final String HAZELCAST_EXPECTED_MEMBERS_PROPERTY = + "gerrit.trigger.coordination.hazelcast.expected.members"; + + /** + * System property: maximum seconds to wait for Hazelcast cluster formation. + * Default: 30 seconds. + */ + public static final String HAZELCAST_CLUSTER_WAIT_TIMEOUT_PROPERTY = + "gerrit.trigger.coordination.hazelcast.cluster.wait.timeout.seconds"; + + private static final int DEFAULT_EXPECTED_CLUSTER_MEMBERS = 1; + + private static final int DEFAULT_CLUSTER_WAIT_TIMEOUT_SECONDS = 30; + + private static final long CLUSTER_WAIT_POLL_INTERVAL_MS = 500L; + /** * The Hazelcast instance for this provider. * Set during initialization, used to create strategies. @@ -208,6 +243,11 @@ public QueueCancellationStrategy createQueueCancellationStrategy() { * Only initializes if coordination mode is configured as 'hazelcast'. * This ensures Hazelcast is not started when using local mode. *

                      + * Also waits for the Hazelcast cluster to reach the expected member count (see + * {@link #HAZELCAST_EXPECTED_MEMBERS_PROPERTY}) before returning, so that + * {@link com.sonyericsson.hudson.plugins.gerrit.trigger.PluginImpl#start()} does not open + * Gerrit server connections until the distributed claim map is shared across replicas. + *

                      * If initialization fails, an exception is thrown and the provider will * not be available (isAvailable() will return false). * @@ -225,6 +265,57 @@ public void initialize() throws Exception { logger.info("Initializing Hazelcast coordination mode..."); this.hazelcastInstance = HazelcastManager.initialize(); logger.info("Hazelcast initialized successfully"); + + waitForClusterFormation(this.hazelcastInstance); + } + + /** + * Waits for the Hazelcast cluster to reach the expected number of members. + *

                      + * In distributed scenarios each replica has its own SSH connection to Gerrit and therefore + * receives every event independently. Without this guard, a replica that starts while + * the Hazelcast cluster is still forming will process events against its own single-member + * IMap, making the distributed claim invisible to other replicas and causing duplicate builds. + * See {@link #HAZELCAST_EXPECTED_MEMBERS_PROPERTY} for when this scenario actually applies - + * in short, an externally-managed cluster, not a per-replica sidecar. + *

                      + * The wait is skipped when {@link #HAZELCAST_EXPECTED_MEMBERS_PROPERTY} is 1 (the default). + * + * @param hz the Hazelcast instance whose cluster membership should be observed + */ + private void waitForClusterFormation(HazelcastInstance hz) { + int expectedMembers = Integer.getInteger(HAZELCAST_EXPECTED_MEMBERS_PROPERTY, + DEFAULT_EXPECTED_CLUSTER_MEMBERS); + if (expectedMembers <= DEFAULT_EXPECTED_CLUSTER_MEMBERS) { + return; + } + + int timeoutSeconds = Integer.getInteger(HAZELCAST_CLUSTER_WAIT_TIMEOUT_PROPERTY, + DEFAULT_CLUSTER_WAIT_TIMEOUT_SECONDS); + logger.info("Waiting for Hazelcast cluster to form ({} expected members, timeout: {}s)...", + expectedMembers, timeoutSeconds); + + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(timeoutSeconds); + int currentSize = hz.getCluster().getMembers().size(); + while (currentSize < expectedMembers && System.currentTimeMillis() < deadline) { + logger.debug("Hazelcast cluster has {} of {} expected members, waiting...", + currentSize, expectedMembers); + try { + Thread.sleep(CLUSTER_WAIT_POLL_INTERVAL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.warn("Interrupted while waiting for Hazelcast cluster formation"); + return; + } + currentSize = hz.getCluster().getMembers().size(); + } + + if (currentSize >= expectedMembers) { + logger.info("Hazelcast cluster ready: {} member(s)", currentSize); + } else { + logger.warn("Timed out waiting for Hazelcast cluster ({}/{} members). " + + "Proceeding anyway - duplicate builds may occur.", currentSize, expectedMembers); + } } /** From 6762df9c598736b8f029e1aa9bbe200d671a9d59 Mon Sep 17 00:00:00 2001 From: sboardwell Date: Wed, 22 Jul 2026 16:55:59 +0200 Subject: [PATCH 73/87] Simplify pipeline-not-yet-started check to FlowExecution presence only Repeated local trials showed an interrupt sent the instant FlowExecution attaches to its owner is always honored, several ms before getCurrentHeads() would report non-empty - so the extra heads check was only adding polling delay, not safety. --- .../HazelcastBuildMemoryStorage.java | 7 ++--- .../hazelcast/PipelineAbortHelper.java | 27 ++++++++++--------- .../hazelcast/PipelineAbortHelperTest.java | 2 +- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 0ce168c81..4ad5376ff 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -292,9 +292,10 @@ private static void handleAbortRequest(String jobName, String buildId, } // For Pipeline builds, wait until the CPS execution has started (i.e. - // FlowExecution.getCurrentHeads() is non-empty) before delivering the interrupt. - // Interrupting during CPS initialisation has no effect — the interrupt flag is - // set before any step is registered, so it is silently lost. + // FlowExecution is attached to its FlowExecutionOwner) before delivering the + // interrupt. Interrupting during CPS initialisation has no effect — the interrupt + // flag is set before FlowExecution exists, so it is silently lost. See + // PipelineAbortHelper for why waiting for FlowExecution alone is sufficient. // We poll every ABORT_RETRY_POLL_MS for up to ABORT_MAX_RETRIES attempts // (ABORT_RETRY_POLL_MS * ABORT_MAX_RETRIES = 3 s total maximum wait). boolean notYetStarted; diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelper.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelper.java index 5c567a4e2..7cd6a4ba8 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelper.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelper.java @@ -34,12 +34,19 @@ * the workflow plugin is present. Callers must guard with a {@code try/catch} for * {@link NoClassDefFoundError} or check plugin availability before calling. *

                      - * Interrupting a Pipeline build during CPS initialisation (before any step has started) - * has no effect — the interrupt flag is silently lost. This helper detects whether the - * CPS program has advanced past initialisation by checking - * {@link FlowExecution#getCurrentHeads()}: an empty list means no {@link - * org.jenkinsci.plugins.workflow.graph.FlowNode} has been created yet, i.e. the pipeline - * has not started executing steps. + * Interrupting a Pipeline build during CPS initialisation (before {@link FlowExecution} is + * attached to its {@link FlowExecutionOwner}) has no effect — the interrupt flag is silently + * lost. This helper detects that window by checking whether {@link FlowExecutionOwner#getOrNull()} + * is still {@code null}. + *

                      + * Why not also wait for {@code FlowExecution.getCurrentHeads()} to be non-empty: + * an earlier version of this check additionally required at least one {@link + * org.jenkinsci.plugins.workflow.graph.FlowNode} to exist. Repeated local trials (interrupting + * a build at the earliest possible moment {@code getOrNull()} became non-null, i.e. strictly + * before any head existed) showed the interrupt was honored (build result {@code ABORTED}) in + * every case, with the heads-non-empty moment consistently arriving several milliseconds later. + * So once {@code FlowExecution} is attached, the interrupt is already deliverable - checking + * heads added no observed protection, only extra polling delay. */ final class PipelineAbortHelper { @@ -63,11 +70,7 @@ static boolean isPipelineNotYetStarted(Run build) { if (owner == null) { return false; } - FlowExecution execution = owner.getOrNull(); - if (execution == null) { - // Execution not yet attached — CPS is still initialising - return true; - } - return execution.getCurrentHeads().isEmpty(); + // Execution not yet attached — CPS is still initialising + return owner.getOrNull() == null; } } diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelperTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelperTest.java index b95e3ce69..531d9eaf9 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelperTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelperTest.java @@ -57,7 +57,7 @@ public void testFreeStyleBuildReturnsFalse() throws Exception { } /** - * A Pipeline build blocked inside a running step has getCurrentHeads() non-empty, + * A Pipeline build blocked inside a running step has its FlowExecution attached, * so isPipelineNotYetStarted() must return false (safe to interrupt). */ @Test From 42fbbbe6a5fe7313771ea1295009ced6f53b97a9 Mon Sep 17 00:00:00 2001 From: sboardwell Date: Wed, 22 Jul 2026 18:24:13 +0200 Subject: [PATCH 74/87] Fix Hazelcast test harness port collision and event-scheduling race EmbeddedHazelcastTestServer bound to a hardcoded port (5702), letting concurrent/successive test runs collide and corrupt each other's cluster state (client "switching cluster" mid-test). It now binds to a free port per run, with HazelcastServerTestListener pointing the client at it. That fix uncovered a second issue: ParameterModeJenkinsTest called waitUntilNoActivity() right after triggerEvent(), racing the background worker's event-claim check (a real network round trip under Hazelcast). Added waitForEventToBeBuilt() to wait for the build to actually be scheduled first. --- .../EmbeddedHazelcastTestServer.java | 49 ++++++++++---- .../HazelcastServerTestListener.java | 13 +++- .../spec/ParameterModeJenkinsTest.java | 64 ++++++++++++++----- 3 files changed, 96 insertions(+), 30 deletions(-) diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EmbeddedHazelcastTestServer.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EmbeddedHazelcastTestServer.java index ea7bc5909..f20f8b8c3 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EmbeddedHazelcastTestServer.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EmbeddedHazelcastTestServer.java @@ -31,12 +31,19 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; +import java.net.ServerSocket; + /** * Starts an embedded Hazelcast member (server) in the test JVM so that * {@link HazelcastManager#initialize()} (which creates a client) has a server to connect to. *

                      - * The server binds to {@code localhost:5702} — the same address the client uses by default - * ({@link HazelcastConfig#DEFAULT_CLIENT_ADDRESS}). + * The server binds to a free port on {@code localhost} chosen at {@link #start()} time, rather + * than a fixed port. Maven Surefire runs multiple forked JVMs concurrently ({@code forkCount} + * in the pom), and a fixed port would let two forks' embedded servers collide on the same + * address, corrupting each other's cluster state mid-test. Callers must read {@link #getPort()} + * after {@link #start()} and point the Hazelcast client at it (see + * {@link HazelcastServerTestListener}). *

                      * This is required because since member mode was removed the plugin only creates a * Hazelcast client, so tests must supply the server themselves. @@ -45,9 +52,8 @@ public final class EmbeddedHazelcastTestServer { private static final Logger logger = LoggerFactory.getLogger(EmbeddedHazelcastTestServer.class); - private static final int TEST_PORT = 5702; - private static volatile HazelcastInstance serverInstance = null; + private static volatile int port = -1; private static final Object LOCK = new Object(); private EmbeddedHazelcastTestServer() { @@ -55,20 +61,23 @@ private EmbeddedHazelcastTestServer() { } /** - * Starts the embedded Hazelcast server if not already running. + * Starts the embedded Hazelcast server if not already running, binding it to a free + * port on localhost. * Idempotent — safe to call multiple times. */ public static void start() { synchronized (LOCK) { if (serverInstance != null && serverInstance.getLifecycleService().isRunning()) { - logger.debug("Embedded Hazelcast test server already running"); + logger.debug("Embedded Hazelcast test server already running on port {}", port); return; } - logger.info("Starting embedded Hazelcast test server on localhost:{}", TEST_PORT); + int chosenPort = findFreePort(); + logger.info("Starting embedded Hazelcast test server on localhost:{}", chosenPort); try { - Config config = buildServerConfig(); + Config config = buildServerConfig(chosenPort); serverInstance = Hazelcast.newHazelcastInstance(config); + port = chosenPort; logger.info("Embedded Hazelcast test server started: {}", serverInstance.getName()); } catch (Exception e) { logger.error("Failed to start embedded Hazelcast test server", e); @@ -77,6 +86,23 @@ public static void start() { } } + /** + * Returns the port the embedded server is bound to. + * + * @return the port, or -1 if the server has not been started yet + */ + public static int getPort() { + return port; + } + + private static int findFreePort() { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } catch (IOException e) { + throw new RuntimeException("Failed to find a free port for the embedded Hazelcast test server", e); + } + } + /** * Shuts down the embedded Hazelcast server if running. * Idempotent — safe to call multiple times. @@ -94,6 +120,7 @@ public static void stop() { logger.warn("Error stopping embedded Hazelcast test server", e); } finally { serverInstance = null; + port = -1; } } } @@ -108,7 +135,7 @@ public static boolean isRunning() { return current != null && current.getLifecycleService().isRunning(); } - private static Config buildServerConfig() { + private static Config buildServerConfig(int testPort) { Config config = new Config(); config.setClusterName(HazelcastConfig.DEFAULT_CLUSTER_NAME); @@ -118,14 +145,14 @@ private static Config buildServerConfig() { config.setProperty("hazelcast.shutdownhook.enabled", "false"); NetworkConfig network = config.getNetworkConfig(); - network.setPort(TEST_PORT); + network.setPort(testPort); network.setPortAutoIncrement(false); network.getInterfaces().setEnabled(true).addInterface("127.0.0.1"); // TCP-IP with only localhost — no multicast, no Kubernetes discovery JoinConfig join = network.getJoin(); join.getMulticastConfig().setEnabled(false); - join.getTcpIpConfig().setEnabled(true).addMember("127.0.0.1:" + TEST_PORT); + join.getTcpIpConfig().setEnabled(true).addMember("127.0.0.1:" + testPort); return config; } diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastServerTestListener.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastServerTestListener.java index b68de456e..0abbd2bae 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastServerTestListener.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastServerTestListener.java @@ -39,8 +39,13 @@ *

                      * Starting the server here ensures that when Jenkins initialises and * {@code PluginImpl.gerritStart()} calls {@link HazelcastManager#initialize()} (which creates a - * Hazelcast client connecting to {@code localhost:5702}), the server is already listening. - * Without this, the client hangs for several minutes trying to reach a non-existent server. + * Hazelcast client), the server is already listening. Without this, the client hangs for + * several minutes trying to reach a non-existent server. + *

                      + * The server binds to a free port chosen per forked JVM (see {@link EmbeddedHazelcastTestServer}), + * so this listener also points the client at that port via + * {@link HazelcastConfig#CLIENT_ADDRESSES_PROPERTY}. Using a fixed, shared port here would let + * concurrent Surefire forks collide on the same address and corrupt each other's cluster state. * * @see EmbeddedHazelcastTestServer */ @@ -60,7 +65,9 @@ public void testPlanExecutionStarted(TestPlan testPlan) { } logger.info("=== Starting embedded Hazelcast test server (coordination mode: {}) ===", mode); EmbeddedHazelcastTestServer.start(); - logger.info("=== Embedded Hazelcast test server ready ==="); + String address = "localhost:" + EmbeddedHazelcastTestServer.getPort(); + System.setProperty(HazelcastConfig.CLIENT_ADDRESSES_PROPERTY, address); + logger.info("=== Embedded Hazelcast test server ready on {} ===", address); } @Override diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/ParameterModeJenkinsTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/ParameterModeJenkinsTest.java index c175cd04c..1f5e86f39 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/ParameterModeJenkinsTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spec/ParameterModeJenkinsTest.java @@ -70,6 +70,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import static org.hamcrest.core.StringContains.containsString; import static org.junit.Assert.assertNotNull; @@ -90,6 +91,16 @@ public class ParameterModeJenkinsTest { private FreeStyleProject job; private GerritTrigger trigger; + /** + * Maximum time to wait for {@link #waitForEventToBeBuilt()} to see the event scheduled. + */ + private static final int SCHEDULE_WAIT_SECONDS = 30; + + /** + * Poll interval for {@link #waitForEventToBeBuilt()} while waiting for scheduling. + */ + private static final int SCHEDULE_POLL_INTERVAL_MILLIS = 50; + /** * Shared setup for all tests. * @@ -165,7 +176,7 @@ public void testNameAndEmailParameterModeDefault() throws Exception { assertSame(GerritTriggerParameters.ParameterMode.PLAIN, trigger.getNameAndEmailParameterMode()); Account ac = new Account("Bobby", "rsandell@cloudbees.com"); PluginImpl.getHandler_().triggerEvent(Setup.createPatchsetCreatedWithAccounts(ac, ac, ac)); - j.waitUntilNoActivity(); + waitForEventToBeBuilt(); FreeStyleBuild build = job.getLastBuild(); List params = Arrays.asList( GerritTriggerParameters.GERRIT_CHANGE_OWNER, @@ -192,7 +203,7 @@ public void testNameAndEmailParameterModeDefaultChangeAbandoned() throws Excepti changeAbandoned.getChange().setOwner(ac); changeAbandoned.setAbandoner(ac); PluginImpl.getHandler_().triggerEvent(changeAbandoned); - j.waitUntilNoActivity(); + waitForEventToBeBuilt(); FreeStyleBuild build = job.getLastBuild(); List params = Arrays.asList( GerritTriggerParameters.GERRIT_CHANGE_OWNER, @@ -220,7 +231,7 @@ public void testNameAndEmailParameterModeDefaultTopicChanged() throws Exception topicChanged.getChange().setOwner(ac); topicChanged.setChanger(ac); PluginImpl.getHandler_().triggerEvent(topicChanged); - j.waitUntilNoActivity(); + waitForEventToBeBuilt(); FreeStyleBuild build = job.getLastBuild(); List params = Arrays.asList( GerritTriggerParameters.GERRIT_CHANGE_OWNER, @@ -249,7 +260,7 @@ public void testNameAndEmailParameterModeDefaultChangeRestored() throws Exceptio change.getChange().setOwner(ac); change.setRestorer(ac); PluginImpl.getHandler_().triggerEvent(change); - j.waitUntilNoActivity(); + waitForEventToBeBuilt(); FreeStyleBuild build = job.getLastBuild(); List params = Arrays.asList( GerritTriggerParameters.GERRIT_CHANGE_OWNER, @@ -275,7 +286,7 @@ public void testNameAndEmailParameterModeDefaultRefUpdated() throws Exception { RefUpdated change = Setup.createRefUpdated(PluginImpl.DEFAULT_SERVER_NAME, "olle", "abc123"); change.setAccount(ac); PluginImpl.getHandler_().triggerEvent(change); - j.waitUntilNoActivity(); + waitForEventToBeBuilt(); FreeStyleBuild build = job.getLastBuild(); List params = Collections.singletonList(GerritTriggerParameters.GERRIT_EVENT_ACCOUNT); //TODO According to the doc GerritTriggerParameters.GERRIT_SUBMITTER should be set as well but its not? @@ -297,7 +308,7 @@ public void testNameAndEmailParameterModeBase64() throws Exception { trigger.setNameAndEmailParameterMode(GerritTriggerParameters.ParameterMode.BASE64); Account ac = new Account("Bobby", "rsandell@cloudbees.com"); PluginImpl.getHandler_().triggerEvent(Setup.createPatchsetCreatedWithAccounts(ac, ac, ac)); - j.waitUntilNoActivity(); + waitForEventToBeBuilt(); FreeStyleBuild build = job.getLastBuild(); List params = Arrays.asList( GerritTriggerParameters.GERRIT_CHANGE_OWNER, @@ -320,7 +331,7 @@ public void testNameAndEmailParameterModeNone() throws Exception { trigger.setNameAndEmailParameterMode(GerritTriggerParameters.ParameterMode.NONE); Account ac = new Account("Bobby", "rsandell@cloudbees.com"); PluginImpl.getHandler_().triggerEvent(Setup.createPatchsetCreatedWithAccounts(ac, ac, ac)); - j.waitUntilNoActivity(); + waitForEventToBeBuilt(); FreeStyleBuild build = job.getLastBuild(); List params = Arrays.asList( GerritTriggerParameters.GERRIT_CHANGE_OWNER, @@ -346,7 +357,7 @@ public void testCommitMessageParameterModeDefault() throws Exception { PatchsetCreated event = Setup.createPatchsetCreated(); event.getChange().setCommitMessage(expected); PluginImpl.getHandler_().triggerEvent(event); - j.waitUntilNoActivity(); + waitForEventToBeBuilt(); FreeStyleBuild build = job.getLastBuild(); assertLogContains(GerritTriggerParameters.GERRIT_CHANGE_COMMIT_MESSAGE.name() + "=" @@ -367,7 +378,7 @@ public void testCommitMessageParameterModePlain() throws Exception { PatchsetCreated event = Setup.createPatchsetCreated(); event.getChange().setCommitMessage(expected); PluginImpl.getHandler_().triggerEvent(event); - j.waitUntilNoActivity(); + waitForEventToBeBuilt(); FreeStyleBuild build = job.getLastBuild(); assertLogContains(GerritTriggerParameters.GERRIT_CHANGE_COMMIT_MESSAGE.name() + "=" @@ -388,7 +399,7 @@ public void testCommitMessageParameterModeNone() throws Exception { PatchsetCreated event = Setup.createPatchsetCreated(); event.getChange().setCommitMessage(expected); PluginImpl.getHandler_().triggerEvent(event); - j.waitUntilNoActivity(); + waitForEventToBeBuilt(); FreeStyleBuild build = job.getLastBuild(); assertLogNotContains(GerritTriggerParameters.GERRIT_CHANGE_COMMIT_MESSAGE.name(), build); } @@ -408,7 +419,7 @@ public void testCommentTextParameterModeDefault() throws Exception { CommentAdded event = Setup.createCommentAdded(); event.setComment(expected); PluginImpl.getHandler_().triggerEvent(event); - j.waitUntilNoActivity(); + waitForEventToBeBuilt(); FreeStyleBuild build = job.getLastBuild(); assertLogContains(GerritTriggerParameters.GERRIT_EVENT_COMMENT_TEXT.name() + "=" @@ -430,7 +441,7 @@ public void testCommentTextParameterModePlain() throws Exception { CommentAdded event = Setup.createCommentAdded(); event.setComment(expected); PluginImpl.getHandler_().triggerEvent(event); - j.waitUntilNoActivity(); + waitForEventToBeBuilt(); FreeStyleBuild build = job.getLastBuild(); assertLogContains(GerritTriggerParameters.GERRIT_EVENT_COMMENT_TEXT.name() + "=" @@ -452,7 +463,7 @@ public void testCommentTextParameterModeNone() throws Exception { CommentAdded event = Setup.createCommentAdded(); event.setComment(expected); PluginImpl.getHandler_().triggerEvent(event); - j.waitUntilNoActivity(); + waitForEventToBeBuilt(); FreeStyleBuild build = job.getLastBuild(); assertLogNotContains(GerritTriggerParameters.GERRIT_EVENT_COMMENT_TEXT.name(), build); } @@ -471,7 +482,7 @@ public void testChangeSubjectParameterModeNone() throws Exception { PatchsetCreated event = Setup.createPatchsetCreated(); event.getChange().setSubject(expected); PluginImpl.getHandler_().triggerEvent(event); - j.waitUntilNoActivity(); + waitForEventToBeBuilt(); FreeStyleBuild build = job.getLastBuild(); assertLogNotContains(GerritTriggerParameters.GERRIT_CHANGE_SUBJECT.name(), build); } @@ -490,7 +501,7 @@ public void testChangeSubjectParameterModeDefault() throws Exception { PatchsetCreated event = Setup.createPatchsetCreated(); event.getChange().setSubject(expected); PluginImpl.getHandler_().triggerEvent(event); - j.waitUntilNoActivity(); + waitForEventToBeBuilt(); FreeStyleBuild build = job.getLastBuild(); assertLogContains(GerritTriggerParameters.GERRIT_CHANGE_SUBJECT.name() + "=" @@ -511,13 +522,34 @@ public void testChangeSubjectParameterModeBase64() throws Exception { PatchsetCreated event = Setup.createPatchsetCreated(); event.getChange().setSubject(expected); PluginImpl.getHandler_().triggerEvent(event); - j.waitUntilNoActivity(); + waitForEventToBeBuilt(); FreeStyleBuild build = job.getLastBuild(); assertLogContains(GerritTriggerParameters.GERRIT_CHANGE_SUBJECT.name() + "=" + GerritTriggerParameters.ParameterMode.encodeBase64(expected), build); } + /** + * Waits for the event fired via {@link PluginImpl#getHandler_()} to be picked up and + * scheduled as a build, then waits for that build to finish. + *

                      + * {@code triggerEvent} hands the event to a background worker thread; with Hazelcast + * coordination the worker's event-claim check does a network round trip, so the item may + * not exist in Jenkins' queue yet at the moment this is called. {@link JenkinsRule + * #waitUntilNoActivity()} alone can't tell "nothing scheduled yet" apart from "already + * finished" — an empty queue looks the same either way — so it can return before the + * build was ever created. + * + * @throws Exception if so + */ + private void waitForEventToBeBuilt() throws Exception { + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(SCHEDULE_WAIT_SECONDS); + while (job.getLastBuild() == null && !job.isInQueue() && System.currentTimeMillis() < deadline) { + Thread.sleep(SCHEDULE_POLL_INTERVAL_MILLIS); + } + j.waitUntilNoActivity(); + } + /** * Asserts that the log contains something. * From fcad4b57908798321aaa1ab54c026b36b61b0aa7 Mon Sep 17 00:00:00 2001 From: Stephan F Date: Wed, 22 Jul 2026 22:36:03 +0100 Subject: [PATCH 75/87] Change of wording for replicas in README --- docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md b/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md index f488cabca..db3ec381b 100644 --- a/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md +++ b/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md @@ -1,6 +1,6 @@ # Distributed Event Management support -The plugin supports Distributed Event Management support where two or more replicas of a Jenkins(*) instance +The plugin supports Distributed Event Management support where two or more replicas or nodes of a logical Jenkins(*) instance run in parallel (sharing the Gerrit memory of the plugin). When enabled, a Hazelcast cluster coordinates the instances so that: From c784cdb52eab1cb9297af78c5cf19628b3acc026 Mon Sep 17 00:00:00 2001 From: Stephan F Date: Wed, 22 Jul 2026 22:49:50 +0100 Subject: [PATCH 76/87] Cluster name explanation update --- docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md b/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md index db3ec381b..a77132244 100644 --- a/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md +++ b/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md @@ -35,7 +35,7 @@ All distributed storage settings are controlled by JVM system properties passed Port `5702` is used by default to avoid potential conflicts with other Hazelcast cluster, which could occupy port `5701`. -Cluster name must be different for each logical instance. Multiple replicas of a single instance may connect to the same cluster name. +Cluster name must be different for each logical instance. Multiple replicas or nodes of a logical instance may configure the same cluster name. Different logical instances require separate cluster names. ### Configuration Example From 53c92014fdcd6cd68e58259b0e986f5ad7de31e6 Mon Sep 17 00:00:00 2001 From: Stephan F Date: Wed, 22 Jul 2026 23:05:18 +0100 Subject: [PATCH 77/87] Update disclaimer --- docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md b/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md index a77132244..5b774b645 100644 --- a/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md +++ b/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md @@ -103,5 +103,5 @@ In this topology: - The Jenkins service account does not need RBAC permissions for peer discovery, as cluster management is handled entirely by the standalone Hazelcast nodes. - You must deploy and manage the Hazelcast cluster independently (e.g. via the official Hazelcast Helm chart), ensuring you configure it to match your expected `HZ_CLUSTERNAME` and port (`5702`). -(*) Jenkins does not support multiple replicas for a single logical instance, this feature is not tested with Jenkins. This feature is provided for CloudBees CI (Enterprise Jenkins). +(*) Jenkins does not support multiple replicas or nodes for a single logical instance, this feature is not tested with Jenkins. This feature is provided for CloudBees CI (Enterprise Jenkins). This feature is provided as a community effort and is not endorsed or officially supported by CloudBees. From 3a9f4fae740930649de7e410cec597713e725e94 Mon Sep 17 00:00:00 2001 From: Stephan F Date: Thu, 23 Jul 2026 13:13:02 +0100 Subject: [PATCH 78/87] Apply suggestions from code review Co-authored-by: Steve Boardwell --- docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md b/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md index 5b774b645..a5d562a03 100644 --- a/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md +++ b/docs/README_DISTRIBUTED_EVENT_MANAGEMENT.md @@ -92,11 +92,11 @@ rules: For larger deployments or strict separation of concerns, you can decouple the coordination layer by running a standalone Hazelcast cluster. Jenkins still connects as a lightweight client, but routes traffic to the separate cluster via a Kubernetes service instead of a sidecar. -Add the following JVM arguments to the Jenkins instance, updating the client address to point to your standalone Hazelcast Kubernetes service (replace hazelcast-service.default.svc.cluster.local with your actual service DNS and namespace): +Add the following JVM arguments to the Jenkins instance, updating the client address to point to your standalone Hazelcast Kubernetes service (replace hazelcast-service.default.svc.cluster.local with your actual service DNS and namespace, along with the cluster name for your logical instance): -Dgerrit.trigger.coordination.mode=hazelcast -Dgerrit.trigger.coordination.hazelcast.client.addresses=hazelcast-service.default.svc.cluster.local:5702 - -Dgerrit.trigger.coordination.hazelcast.client.cluster.name=gerrit-trigger-cluster + -Dgerrit.trigger.coordination.hazelcast.client.cluster.name=gerrit-trigger-cluster- In this topology: - You do not need to add the sidecar container to the Jenkins pod spec. From 130e72871fcd833d16aa0e53267ef336fef241c1 Mon Sep 17 00:00:00 2001 From: sboardwell Date: Thu, 23 Jul 2026 15:27:24 +0200 Subject: [PATCH 79/87] Rename EventIdentifier to EventIdGenerator, remove unused BuildMemoryKey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventIdGenerator is a shared utility (used by both claim strategies, not just BuildMemoryKey), so the rename clarifies its role. BuildMemoryKey had no real callers — HazelcastBuildMemoryStorage deliberately uses raw String keys instead to avoid Hazelcast classloader issues. --- .../hazelcast/BuildMemoryKey.java | 98 ------------------- .../coordination/hazelcast/EventClaim.java | 2 +- ...tIdentifier.java => EventIdGenerator.java} | 4 +- .../HazelcastBuildMemoryStorage.java | 28 +++--- .../HazelcastEventClaimStrategy.java | 2 +- .../HazelcastNotificationClaimStrategy.java | 2 +- .../gerritnotifier/model/BuildMemory.java | 2 +- .../trigger/spi/BuildMemoryStorage.java | 2 +- 8 files changed, 21 insertions(+), 119 deletions(-) delete mode 100644 src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildMemoryKey.java rename src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/{EventIdentifier.java => EventIdGenerator.java} (99%) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildMemoryKey.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildMemoryKey.java deleted file mode 100644 index 22c1b5277..000000000 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/BuildMemoryKey.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * The MIT License - * - * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; - -import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; -import java.io.Serializable; -import java.util.Objects; - -/** - * Key class for BuildMemory entries in Hazelcast. - *

                      - * Uses event ID instead of event object for serialization efficiency. - * The event ID is deterministic (same event on different replicas produces same ID). - * - */ -public class BuildMemoryKey implements Serializable { - - private static final long serialVersionUID = 1L; - - private String eventId; - - /** - * No-arg constructor for serialization. - */ - public BuildMemoryKey() { - this.eventId = null; - } - - /** - * Constructor from GerritTriggeredEvent. - * - * @param event the Gerrit event - */ - public BuildMemoryKey(GerritTriggeredEvent event) { - this.eventId = EventIdentifier.generateEventId(event); - } - - /** - * Constructor from event ID string. - * - * @param eventId the event identifier - */ - public BuildMemoryKey(String eventId) { - this.eventId = eventId; - } - - /** - * Gets the event identifier. - * - * @return event ID - */ - public String getEventId() { - return eventId; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - BuildMemoryKey that = (BuildMemoryKey)o; - return Objects.equals(eventId, that.eventId); - } - - @Override - public int hashCode() { - return Objects.hash(eventId); - } - - @Override - public String toString() { - return "BuildMemoryKey{eventId='" + eventId + "'}"; - } -} diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaim.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaim.java index 1a1ce1623..c4af702a4 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaim.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventClaim.java @@ -41,7 +41,7 @@ public class EventClaim { /** - * Unique event identifier (generated by {@link EventIdentifier}). + * Unique event identifier (generated by {@link EventIdGenerator}). */ private final String eventId; diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdGenerator.java similarity index 99% rename from src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java rename to src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdGenerator.java index 657fb3d76..42f3a58cc 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdentifier.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/EventIdGenerator.java @@ -48,7 +48,7 @@ * (replica timestamp) to ensure identical event IDs across all replicas receiving the same event. * */ -public final class EventIdentifier { +public final class EventIdGenerator { /** * Length of short Git revision hash (first 8 characters). @@ -58,7 +58,7 @@ public final class EventIdentifier { /** * Private constructor to prevent instantiation. */ - private EventIdentifier() { + private EventIdGenerator() { // Utility class } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 4ad5376ff..a1b1112fc 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -411,7 +411,7 @@ public synchronized MemoryImprint getMemoryImprint(@NonNull GerritTriggeredEvent return null; } - String key = EventIdentifier.generateEventId(event); + String key = EventIdGenerator.generateEventId(event); MemoryImprintData data = map.get(key); if (data != null) { return MemoryImprint.fromData(data); @@ -427,7 +427,7 @@ public synchronized void triggered(@NonNull GerritTriggeredEvent event, @NonNull return; } - String key = EventIdentifier.generateEventId(event); + String key = EventIdGenerator.generateEventId(event); String projectFullName = project.getFullName(); // ATOMIC OPERATION - Distributed lock ensures only one replica modifies this entry at a time. @@ -483,7 +483,7 @@ public synchronized void started(@NonNull GerritTriggeredEvent event, @NonNull R return; } - String key = EventIdentifier.generateEventId(event); + String key = EventIdGenerator.generateEventId(event); String projectFullName = build.getParent().getFullName(); String buildId = build.getId(); @@ -577,7 +577,7 @@ public synchronized void completed(@NonNull GerritTriggeredEvent event, @NonNull return; } - String key = EventIdentifier.generateEventId(event); + String key = EventIdGenerator.generateEventId(event); String projectFullName = build.getParent().getFullName(); String buildId = build.getId(); @@ -638,7 +638,7 @@ public synchronized void retriggered(@NonNull GerritTriggeredEvent event, @NonNu return; } - String key = EventIdentifier.generateEventId(event); + String key = EventIdGenerator.generateEventId(event); String projectFullName = project.getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). @@ -698,7 +698,7 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull return; } - String key = EventIdentifier.generateEventId(event); + String key = EventIdGenerator.generateEventId(event); String projectFullName = project.getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). @@ -874,7 +874,7 @@ public synchronized void setCancelling(@NonNull GerritTriggeredEvent event, @Non return; } - String key = EventIdentifier.generateEventId(event); + String key = EventIdGenerator.generateEventId(event); String projectFullName = project.getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). @@ -926,7 +926,7 @@ public synchronized void requestCrossReplicaAbort(@NonNull GerritTriggeredEvent return; } - String key = EventIdentifier.generateEventId(event); + String key = EventIdGenerator.generateEventId(event); String projectFullName = project.getFullName(); String causeType; if (cause instanceof AbandonedPatchsetInterruption) { @@ -971,7 +971,7 @@ public synchronized void forget(@NonNull GerritTriggeredEvent event) { return; } - String key = EventIdentifier.generateEventId(event); + String key = EventIdGenerator.generateEventId(event); map.remove(key); logger.trace("Forgot event from distributed memory: {}", key); } @@ -1133,7 +1133,7 @@ public void setEntryCustomUrl(@NonNull GerritTriggeredEvent event, @NonNull Run return; } - String key = EventIdentifier.generateEventId(event); + String key = EventIdGenerator.generateEventId(event); String projectFullName = r.getParent().getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). @@ -1179,7 +1179,7 @@ public void setEntryUnsuccessfulMessage(@NonNull GerritTriggeredEvent event, @No return; } - String key = EventIdentifier.generateEventId(event); + String key = EventIdGenerator.generateEventId(event); String projectFullName = r.getParent().getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). @@ -1271,10 +1271,10 @@ public synchronized Map getAllEvents() { @Override public boolean eventsMatch(@NonNull GerritTriggeredEvent event1, @NonNull GerritTriggeredEvent event2) { - // In distributed mode, use logical comparison via EventIdentifier + // In distributed mode, use logical comparison via EventIdGenerator // because events may be deserialized from Hazelcast, creating new object instances - String id1 = EventIdentifier.generateEventId(event1); - String id2 = EventIdentifier.generateEventId(event2); + String id1 = EventIdGenerator.generateEventId(event1); + String id2 = EventIdGenerator.generateEventId(event2); return id1.equals(id2); } } diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java index 568896f8a..f86d4ae22 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastEventClaimStrategy.java @@ -118,7 +118,7 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, @NonNull Runna } // Generate event ID - String eventId = EventIdentifier.generateEventId(event); + String eventId = EventIdGenerator.generateEventId(event); String thisInstanceId = getInstanceId(); try { diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java index cb1dd8d19..076f3e807 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastNotificationClaimStrategy.java @@ -118,7 +118,7 @@ public ClaimResult withClaim(@NonNull GerritTriggeredEvent event, try { IMap notificationFlags = hazelcastInstance.getMap(NOTIFICATION_FLAGS_MAP); - String eventId = EventIdentifier.generateEventId(event); + String eventId = EventIdGenerator.generateEventId(event); // Build claim key: // - With job identifier: per-job claim (e.g., build-started notifications) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index b4199ace0..e1635fbb5 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -680,7 +680,7 @@ private void cancelMatchingJobs( * This respects the abstraction boundary: *

                        *
                      • Local mode: Uses identity comparison (==)
                      • - *
                      • Distributed mode: Uses logical comparison via EventIdentifier + *
                      • Distributed mode: Uses logical comparison via EventIdGenerator * since events may be deserialized
                      • *
                      * diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java index c79a29d3d..6125b0359 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/spi/BuildMemoryStorage.java @@ -319,7 +319,7 @@ public void requestCrossReplicaAbort(@NonNull GerritTriggeredEvent event, @NonNu * This deliberately ignores timestamp so that deserialized event instances * (e.g. {@code GerritCause.tEvent} loaded from disk) correctly match against * in-memory events representing the same logical change. - *
                    • Distributed mode: Uses {@link com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.EventIdentifier#generateEventId}, + *
                    • Distributed mode: Uses {@link com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast.EventIdGenerator#generateEventId}, * which produces a deterministic string key including the server-side timestamp. * The timestamp is needed because the IMap key must uniquely identify each * event reception across replicas; without it, two different events on the From f4429bbfd4f545b549ddf4dd4cb06d22eeac421a Mon Sep 17 00:00:00 2001 From: sboardwell Date: Thu, 23 Jul 2026 16:42:12 +0200 Subject: [PATCH 80/87] Move PipelineAbortHelper to hudsontrigger package Relocate PipelineAbortHelper (and its test) out of coordination.hazelcast into hudsontrigger, widening it from package-private to public so HazelcastBuildMemoryStorage can use it across the package boundary. --- .../coordination/hazelcast/HazelcastBuildMemoryStorage.java | 1 + .../hazelcast => hudsontrigger}/PipelineAbortHelper.java | 6 +++--- .../PipelineAbortHelperTest.java | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) rename src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/{coordination/hazelcast => hudsontrigger}/PipelineAbortHelper.java (95%) rename src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/{coordination/hazelcast => hudsontrigger}/PipelineAbortHelperTest.java (98%) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index a1b1112fc..3eb43178a 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -38,6 +38,7 @@ import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.AbandonedPatchsetInterruption; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.GerritCause; import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.NewPatchSetInterruption; +import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.PipelineAbortHelper; import com.sonyericsson.hudson.plugins.gerrit.trigger.spi.BuildMemoryStorage; import com.sonymobile.tools.gerrit.gerritevents.dto.events.GerritTriggeredEvent; import hudson.model.Computer; diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelper.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/PipelineAbortHelper.java similarity index 95% rename from src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelper.java rename to src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/PipelineAbortHelper.java index 7cd6a4ba8..539959216 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelper.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/PipelineAbortHelper.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ -package com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; +package com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger; import hudson.model.Run; import org.jenkinsci.plugins.workflow.flow.FlowExecution; @@ -48,7 +48,7 @@ * So once {@code FlowExecution} is attached, the interrupt is already deliverable - checking * heads added no observed protection, only extra polling delay. */ -final class PipelineAbortHelper { +public final class PipelineAbortHelper { private PipelineAbortHelper() { } @@ -62,7 +62,7 @@ private PipelineAbortHelper() { } * @param build the build to check * @return true if the build is a pipeline still initialising */ - static boolean isPipelineNotYetStarted(Run build) { + public static boolean isPipelineNotYetStarted(Run build) { if (!(build instanceof FlowExecutionOwner.Executable)) { return false; } diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelperTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/PipelineAbortHelperTest.java similarity index 98% rename from src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelperTest.java rename to src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/PipelineAbortHelperTest.java index 531d9eaf9..75e573743 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/PipelineAbortHelperTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/PipelineAbortHelperTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ -package com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; +package com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger; import hudson.model.FreeStyleBuild; import hudson.model.FreeStyleProject; From b2715e5fbbe99f196ba855a1e1f434d67f6a3899 Mon Sep 17 00:00:00 2001 From: sboardwell Date: Thu, 23 Jul 2026 16:49:00 +0200 Subject: [PATCH 81/87] Generalise message --- .../gerrit/trigger/gerritnotifier/model/BuildMemory.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index e1635fbb5..a71951533 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -662,9 +662,8 @@ private void cancelMatchingJobs( } } - // Notify other replicas to abort matching builds on their local executors. - // In standalone mode this is a no-op; in distributed mode the storage - // puts a cause-typed entry into the abort inbox IMap. + // Ask the storage implementation to notify other replicas, if any, to + // abort matching builds on their local executors. storage.requestCrossReplicaAbort(event, job, cause); } catch (Exception e) { logger.error("Error canceling job", e); From 392da69cd12fbf0285d12ef6c33f3b22b0cb134e Mon Sep 17 00:00:00 2001 From: sboardwell Date: Sun, 26 Jul 2026 18:18:56 +0200 Subject: [PATCH 82/87] Remove mentions of HZ-* tests and mc* controllers --- .gitignore | 1 + .../hazelcast/HazelcastBuildMemoryStorage.java | 2 +- .../gerritnotifier/model/BuildMemory.java | 16 +++++++--------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 1d8a56543..3b3083cf6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ hostkey.ser /eclipse-classes .vscode/ .factorypath +graphify-out diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 3eb43178a..603cfc0d2 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -1100,7 +1100,7 @@ public synchronized boolean isBuilding(@NonNull GerritTriggeredEvent event) { // queueLeft entries (load-balanced moves or direct doCancelItem) are treated as // inactive — they are not running on this replica. This allows isBuilding(event) // to return false for the unit-test case (direct doCancelItem) while preserving - // the IMap key for cross-replica PS2-aborts-PS1 scenarios (HZ-004). + // the IMap key for cross-replica PS2-aborts-PS1 scenarios. for (MemoryImprint.Entry entry : imprint.getEntries()) { if (!entry.isBuildCompleted() && !entry.isQueueLeft()) { return true; diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index a71951533..53605f932 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -392,9 +392,8 @@ public void cancelOutdatedEvents( // loop only ever asks "should I cancel the OTHER, already-registered // event?" - it never asks the reverse. Left unguarded, a late-arriving // older patchset's own build runs to completion fully unsuppressed - // whenever cross-replica event delivery reorders patchset arrival - // (confirmed to happen on mc3 - see the HZ-104 cross-node cancellation - // race writeup). Detect that case here so newEvent gets cancelled too, + // whenever cross-replica event delivery reorders patchset arrival. + // Detect that case here so newEvent gets cancelled too, // via the same isCancelling + deferred-abort machinery already used for // the normal direction. if (isNewEventOutdatedByRunningEvent(newEvent, runningChangeBasedEvent, jobName, entry.getValue())) { @@ -414,11 +413,11 @@ public void cancelOutdatedEvents( imprintEntry.isCancelling(), imprintEntry.isCancelled()); // Deliberately does NOT check !imprintEntry.isQueueLeft(): queueLeft means // "left the queue for an ambiguous reason (possibly a load-balanced - // relocation to another mc3 replica), not yet confirmed as a genuine + // relocation to another replica), not yet confirmed as a genuine // cancel" - it is not itself proof the entry is done. Excluding it here // let relocated-but-not-yet-restarted entries dodge cancellation entirely - // whenever a newer patchset arrived during the relocation window (the - // HZ-006/HZ-104 mc3 race). + // whenever a newer patchset arrived during the relocation window + // (a cross-replica race). if (imprintEntry.isProject(jobName) && !imprintEntry.isBuildCompleted() && !imprintEntry.isCancelling() @@ -551,8 +550,7 @@ private boolean shouldIgnoreEvent( * {@code newEvent} - it correctly refuses to do so when {@code runningEvent} is actually * newer, but nothing then cancels {@code newEvent} itself in that case. Cross-replica event * delivery can deliver a newer patchset's event to some replica before an older one reaches - * any replica at all (confirmed on {@code mc3} - see the HZ-104 cross-node cancellation race - * writeup), which is exactly when this matters: without this check, the late-arriving, + * any replica at all, which is exactly when this matters: without this check, the late-arriving, * actually-outdated {@code newEvent} would never recognize itself as such and would run to * completion alongside the newer patchset that's already building. *

                      @@ -1532,7 +1530,7 @@ public void setCancelled(boolean cancelled) { *

                    * Unlike {@link #isCancelled()}, setting this flag does NOT also set * {@link #setBuildCompleted(boolean)}, preserving the IMap entry for cross-instance - * new-patchset abort scenarios (HZ-004). + * new-patchset abort scenarios. * * @return true if the queue item left without a prior cancelling intent */ From a7025efc89f62d9ef1dd4b8a4323ec30f0b60381 Mon Sep 17 00:00:00 2001 From: sboardwell Date: Mon, 27 Jul 2026 08:18:21 +0200 Subject: [PATCH 83/87] Refactor locking to fluent withLock/onFailure Replaces the repeated tryLockWithTimeout/try-finally pattern with a withLock(action).onFailure(...) helper across HazelcastBuildMemoryStorage, to keep the locking style consistent. --- .../HazelcastBuildMemoryStorage.java | 798 +++++++++--------- 1 file changed, 413 insertions(+), 385 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java index 603cfc0d2..bd0b32a0f 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastBuildMemoryStorage.java @@ -63,6 +63,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; /** * Hazelcast-backed implementation of BuildMemoryStorage for distributed scenarios. @@ -402,6 +403,64 @@ private static boolean tryLockWithTimeout(IMap map, S } } + /** + * Code to run while a distributed lock is held, passed to {@link #withLock}. + */ + @FunctionalInterface + private interface LockedAction { + void run(); + } + + /** + * Outcome of a {@link #withLock} call, letting the caller react if the lock was never + * acquired (in which case the {@link LockedAction} did not run at all). + */ + private static final class LockOutcome { + private final boolean acquired; + + private LockOutcome(boolean acquired) { + this.acquired = acquired; + } + + /** + * Runs {@code action} only if the lock could not be acquired. + * + * @param action ran when the lock was not acquired within {@link #LOCK_ACQUIRE_TIMEOUT_SECONDS} + */ + void onFailure(Runnable action) { + if (!acquired) { + action.run(); + } + } + } + + /** + * Runs {@code action} while holding the distributed lock on {@code key}, always releasing + * the lock afterwards. If the lock cannot be acquired within + * {@link #LOCK_ACQUIRE_TIMEOUT_SECONDS}, {@code action} is not run at all; use the returned + * {@link LockOutcome#onFailure} to react to that case (e.g. logging and skipping the + * operation). + *

                    + * {@code action} is responsible for catching and logging its own exceptions - this method + * only manages lock acquisition and release, not business-logic error handling. + * + * @param map the distributed map that owns the lock + * @param key the key to lock + * @param action the code to run while the lock is held + * @return a {@link LockOutcome} - chain {@link LockOutcome#onFailure} to react to lock failure + */ + private LockOutcome withLock(IMap map, String key, LockedAction action) { + if (!tryLockWithTimeout(map, key)) { + return new LockOutcome(false); + } + try { + action.run(); + } finally { + map.unlock(key); + } + return new LockOutcome(true); + } + // ===== Implement BuildMemoryStorage abstract methods ===== @Override @@ -437,43 +496,41 @@ public synchronized void triggered(@NonNull GerritTriggeredEvent event, @NonNull // causing some project entries to be lost from BuildMemory (which breaks cancellation logic). // Note: EntryProcessor is NOT used here because in client mode the processor class would need // to exist on the Hazelcast sidecar member's classpath, causing ClassNotFoundException. - if (!tryLockWithTimeout(map, key)) { - logger.error("Could not acquire distributed lock for key {} within {}s - skipping triggered()", - key, LOCK_ACQUIRE_TIMEOUT_SECONDS); - return; - } - try { - MemoryImprintData data = map.get(key); - if (data == null) { - data = new MemoryImprintData(); - data.setEvent(event); - } - boolean found = false; - if (data.getEntries() != null) { - for (EntryData entryData : data.getEntries()) { - if (projectFullName.equals(entryData.getProjectFullName())) { - found = true; - break; + withLock(map, key, () -> { + try { + MemoryImprintData data = map.get(key); + if (data == null) { + data = new MemoryImprintData(); + data.setEvent(event); + } + boolean found = false; + if (data.getEntries() != null) { + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + found = true; + break; + } } } + if (!found) { + EntryData newEntry = new EntryData(); + newEntry.setProjectFullName(projectFullName); + data.addEntry(newEntry); + } + map.put(key, data); + if (!found) { + logger.trace("Triggered event stored in distributed memory: {} for project: {}", + key, projectFullName); + } else { + logger.trace("Project {} already triggered for event: {}", projectFullName, key); + } + } catch (Exception e) { + logger.error("Failed to store triggered event in distributed memory for project: {} event: {}", + projectFullName, key, e); } - if (!found) { - EntryData newEntry = new EntryData(); - newEntry.setProjectFullName(projectFullName); - data.addEntry(newEntry); - } - map.put(key, data); - if (!found) { - logger.trace("Triggered event stored in distributed memory: {} for project: {}", key, projectFullName); - } else { - logger.trace("Project {} already triggered for event: {}", projectFullName, key); - } - } catch (Exception e) { - logger.error("Failed to store triggered event in distributed memory for project: {} event: {}", - projectFullName, key, e); - } finally { - map.unlock(key); - } + }).onFailure(() -> logger.error( + "Could not acquire distributed lock for key {} within {}s - skipping triggered()", + key, LOCK_ACQUIRE_TIMEOUT_SECONDS)); } @Override @@ -495,56 +552,52 @@ public synchronized void started(@NonNull GerritTriggeredEvent event, @NonNull R // build's buildId was written to Hazelcast (buildId was null at that time, so no abort // inbox entry was written). We detect this by checking isCancelling on the entry after // writing the buildId, and then write the abort inbox entry here. - boolean pendingCrossReplicaAbort = false; - if (!tryLockWithTimeout(map, key)) { - logger.error("Could not acquire distributed lock for key {} within {}s - skipping started()", - key, LOCK_ACQUIRE_TIMEOUT_SECONDS); - return; - } - try { - MemoryImprintData data = map.get(key); - if (data == null) { - data = new MemoryImprintData(); - } - boolean found = false; - if (data.getEntries() != null) { - for (EntryData entryData : data.getEntries()) { - if (projectFullName.equals(entryData.getProjectFullName())) { - entryData.setBuildId(buildId); - entryData.setStartedTimestamp(startedTimestamp); - // The build actually started on a replica — clear any queueLeft flag that - // was set when the queue item was moved by a load balancer. The entry is - // now actively building and must be visible to cancelOutdatedBuilds again. - entryData.setQueueLeft(false); - found = true; - // If this entry is already marked for cancellation, we need to trigger - // cross-replica abort now that buildId is known. - if (entryData.isCancelling()) { - pendingCrossReplicaAbort = true; + AtomicBoolean pendingCrossReplicaAbort = new AtomicBoolean(false); + withLock(map, key, () -> { + try { + MemoryImprintData data = map.get(key); + if (data == null) { + data = new MemoryImprintData(); + } + boolean found = false; + if (data.getEntries() != null) { + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + entryData.setBuildId(buildId); + entryData.setStartedTimestamp(startedTimestamp); + // The build actually started on a replica — clear any queueLeft flag that + // was set when the queue item was moved by a load balancer. The entry is + // now actively building and must be visible to cancelOutdatedBuilds again. + entryData.setQueueLeft(false); + found = true; + // If this entry is already marked for cancellation, we need to trigger + // cross-replica abort now that buildId is known. + if (entryData.isCancelling()) { + pendingCrossReplicaAbort.set(true); + } + break; } - break; } } + if (!found) { + EntryData newEntry = new EntryData(); + newEntry.setProjectFullName(projectFullName); + newEntry.setBuildId(buildId); + newEntry.setStartedTimestamp(startedTimestamp); + data.setEvent(event); + data.addEntry(newEntry); + } + map.put(key, data); + if (!found) { + logger.warn("Build started without being registered first (distributed mode)."); + } + logger.trace("Build started event stored in distributed memory: {}", key); + } catch (Exception e) { + logger.error("Failed to mark build started in distributed memory: project={}, build={}, event={}", + projectFullName, buildId, key, e); } - if (!found) { - EntryData newEntry = new EntryData(); - newEntry.setProjectFullName(projectFullName); - newEntry.setBuildId(buildId); - newEntry.setStartedTimestamp(startedTimestamp); - data.setEvent(event); - data.addEntry(newEntry); - } - map.put(key, data); - if (!found) { - logger.warn("Build started without being registered first (distributed mode)."); - } - logger.trace("Build started event stored in distributed memory: {}", key); - } catch (Exception e) { - logger.error("Failed to mark build started in distributed memory: project={}, build={}, event={}", - projectFullName, buildId, key, e); - } finally { - map.unlock(key); - } + }).onFailure(() -> logger.error("Could not acquire distributed lock for key {} within {}s - skipping started()", + key, LOCK_ACQUIRE_TIMEOUT_SECONDS)); // Deferred cross-replica abort: if the entry was already marked for cancellation when this // build started, requestCrossReplicaAbort() previously found buildId=null and could not @@ -552,7 +605,7 @@ public synchronized void started(@NonNull GerritTriggeredEvent event, @NonNull R // a short delay so the CPS pipeline has time to complete initialization before the // interrupt is delivered. Firing the interrupt too early (during CPS init, before any // step begins) has no effect — the interrupt flag is set on the wrong thread context. - if (pendingCrossReplicaAbort && hazelcastInstance != null) { + if (pendingCrossReplicaAbort.get() && hazelcastInstance != null) { final HazelcastInstance hz = hazelcastInstance; final String abortKey = projectFullName + ":" + buildId; logger.info("Scheduling deferred cross-replica abort in {}s (started race): job={} build={}", @@ -584,50 +637,47 @@ public synchronized void completed(@NonNull GerritTriggeredEvent event, @NonNull // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). long completedTimestamp = System.currentTimeMillis(); - if (!tryLockWithTimeout(map, key)) { - logger.error("Could not acquire distributed lock for key {} within {}s - skipping completed()", - key, LOCK_ACQUIRE_TIMEOUT_SECONDS); - return; - } - try { - MemoryImprintData data = map.get(key); - if (data == null) { - data = new MemoryImprintData(); - } - boolean found = false; - if (data.getEntries() != null) { - for (EntryData entryData : data.getEntries()) { - if (projectFullName.equals(entryData.getProjectFullName())) { - if (entryData.getBuildId() == null) { - entryData.setBuildId(buildId); + withLock(map, key, () -> { + try { + MemoryImprintData data = map.get(key); + if (data == null) { + data = new MemoryImprintData(); + } + boolean found = false; + if (data.getEntries() != null) { + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + if (entryData.getBuildId() == null) { + entryData.setBuildId(buildId); + } + entryData.setCompletedTimestamp(completedTimestamp); + entryData.setBuildCompleted(true); + found = true; + break; } - entryData.setCompletedTimestamp(completedTimestamp); - entryData.setBuildCompleted(true); - found = true; - break; } } + if (!found) { + EntryData newEntry = new EntryData(); + newEntry.setProjectFullName(projectFullName); + newEntry.setBuildId(buildId); + newEntry.setCompletedTimestamp(completedTimestamp); + newEntry.setBuildCompleted(true); + data.setEvent(event); + data.addEntry(newEntry); + } + map.put(key, data); + if (!found) { + logger.debug("Build completed without being registered first (distributed mode)."); + } + logger.trace("Build completed event stored in distributed memory: {}", key); + } catch (Exception e) { + logger.error("Failed to mark build completed in distributed memory: project={}, build={}, event={}", + projectFullName, buildId, key, e); } - if (!found) { - EntryData newEntry = new EntryData(); - newEntry.setProjectFullName(projectFullName); - newEntry.setBuildId(buildId); - newEntry.setCompletedTimestamp(completedTimestamp); - newEntry.setBuildCompleted(true); - data.setEvent(event); - data.addEntry(newEntry); - } - map.put(key, data); - if (!found) { - logger.debug("Build completed without being registered first (distributed mode)."); - } - logger.trace("Build completed event stored in distributed memory: {}", key); - } catch (Exception e) { - logger.error("Failed to mark build completed in distributed memory: project={}, build={}, event={}", - projectFullName, buildId, key, e); - } finally { - map.unlock(key); - } + }).onFailure(() -> logger.error( + "Could not acquire distributed lock for key {} within {}s - skipping completed()", + key, LOCK_ACQUIRE_TIMEOUT_SECONDS)); } @Override @@ -643,52 +693,49 @@ public synchronized void retriggered(@NonNull GerritTriggeredEvent event, @NonNu String projectFullName = project.getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). - if (!tryLockWithTimeout(map, key)) { - logger.error("Could not acquire distributed lock for key {} within {}s - skipping retriggered()", - key, LOCK_ACQUIRE_TIMEOUT_SECONDS); - return; - } - try { - MemoryImprintData data = map.get(key); - if (data == null) { - data = new MemoryImprintData(); - data.setEvent(event); - if (otherBuilds != null) { - for (Run otherBuild : otherBuilds) { - EntryData entryData = new EntryData(); - entryData.setProjectFullName(otherBuild.getParent().getFullName()); - entryData.setBuildId(otherBuild.getId()); - entryData.setBuildCompleted(!otherBuild.isBuilding()); - data.addEntry(entryData); + withLock(map, key, () -> { + try { + MemoryImprintData data = map.get(key); + if (data == null) { + data = new MemoryImprintData(); + data.setEvent(event); + if (otherBuilds != null) { + for (Run otherBuild : otherBuilds) { + EntryData entryData = new EntryData(); + entryData.setProjectFullName(otherBuild.getParent().getFullName()); + entryData.setBuildId(otherBuild.getId()); + entryData.setBuildCompleted(!otherBuild.isBuilding()); + data.addEntry(entryData); + } } } - } - boolean found = false; - if (data.getEntries() != null) { - for (EntryData entryData : data.getEntries()) { - if (projectFullName.equals(entryData.getProjectFullName())) { - entryData.setBuildId(null); - entryData.setBuildCompleted(false); - entryData.setStartedTimestamp(null); - entryData.setCompletedTimestamp(null); - found = true; - break; + boolean found = false; + if (data.getEntries() != null) { + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + entryData.setBuildId(null); + entryData.setBuildCompleted(false); + entryData.setStartedTimestamp(null); + entryData.setCompletedTimestamp(null); + found = true; + break; + } } } + if (!found) { + EntryData newEntry = new EntryData(); + newEntry.setProjectFullName(projectFullName); + data.addEntry(newEntry); + } + map.put(key, data); + logger.trace("Retriggered event stored in distributed memory: {}", key); + } catch (Exception e) { + logger.error("Failed to record retriggered in distributed memory: project={}, event={}", + projectFullName, key, e); } - if (!found) { - EntryData newEntry = new EntryData(); - newEntry.setProjectFullName(projectFullName); - data.addEntry(newEntry); - } - map.put(key, data); - logger.trace("Retriggered event stored in distributed memory: {}", key); - } catch (Exception e) { - logger.error("Failed to record retriggered in distributed memory: project={}, event={}", - projectFullName, key, e); - } finally { - map.unlock(key); - } + }).onFailure(() -> logger.error( + "Could not acquire distributed lock for key {} within {}s - skipping retriggered()", + key, LOCK_ACQUIRE_TIMEOUT_SECONDS)); } @Override @@ -703,84 +750,81 @@ public synchronized void cancelled(@NonNull GerritTriggeredEvent event, @NonNull String projectFullName = project.getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). - if (!tryLockWithTimeout(map, key)) { - logger.error("Could not acquire distributed lock for key {} within {}s - skipping cancelled()", - key, LOCK_ACQUIRE_TIMEOUT_SECONDS); - return; - } // Set when this queue exit is ambiguous (isCancelling was already true) and needs a // deferred re-check after the lock is released - see CANCEL_FINALIZE_GRACE_SECONDS. - boolean scheduleFinalizeCheck = false; - try { - MemoryImprintData data = map.get(key); - if (data == null) { - data = new MemoryImprintData(); - } - boolean found = false; - boolean modified = false; - if (data.getEntries() != null) { - for (EntryData entryData : data.getEntries()) { - if (projectFullName.equals(entryData.getProjectFullName())) { - found = true; - if (!entryData.isBuildCompleted()) { - if (entryData.isCancelling()) { - // Ambiguous: the intent was set via setCancelling() first (e.g. by - // cancelOutdatedBuilds), but this queue exit itself could equally be - // a genuine Gerrit-triggered cancellation OR a benign cross-replica - // queue-item relocation - isLoadBalancedCancellation() can't tell - // them apart (see its own doc comment; Jenkins preserves no marker - // on LeftItem either way). Don't finalize yet: mark queueLeft (not - // completed) and leave isCancelling=true so started()'s own - // deferred-abort compensator still fires correctly if this really is - // a relocation and the build reports in from wherever it landed. - // scheduleDeferredCancelFinalize (below, after the lock is released) - // decides the real outcome after a grace period. - entryData.setQueueLeft(true); - scheduleFinalizeCheck = true; - } else if (entryData.getBuildId() == null) { - // No prior cancellation intent AND build has not started anywhere. - // This is a potential load-balanced queue move (build will restart - // on another instance) or a direct Queue.doCancelItem before start. - // Mark queueLeft=true but do NOT set buildCompleted=true so the - // IMap entry is preserved for cross-instance PS2-aborts-PS1 scenarios. - // NOTE: if buildId IS already set, started() already ran on another - // instance — leave the entry completely untouched so it stays visible - // to cancelOutdatedBuilds on that instance. - entryData.setQueueLeft(true); - logger.info("Marking queueLeft (pre-cancellation-intent queue exit, no " - + "buildId yet) for project={} event={} - possible load-balanced " - + "relocation with no started() call yet", projectFullName, key); + AtomicBoolean scheduleFinalizeCheck = new AtomicBoolean(false); + withLock(map, key, () -> { + try { + MemoryImprintData data = map.get(key); + if (data == null) { + data = new MemoryImprintData(); + } + boolean found = false; + boolean modified = false; + if (data.getEntries() != null) { + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + found = true; + if (!entryData.isBuildCompleted()) { + if (entryData.isCancelling()) { + // Ambiguous: the intent was set via setCancelling() first (e.g. by + // cancelOutdatedBuilds), but this queue exit itself could equally be + // a genuine Gerrit-triggered cancellation OR a benign cross-replica + // queue-item relocation - isLoadBalancedCancellation() can't tell + // them apart (see its own doc comment; Jenkins preserves no marker + // on LeftItem either way). Don't finalize yet: mark queueLeft (not + // completed) and leave isCancelling=true so started()'s own + // deferred-abort compensator still fires correctly if this really is + // a relocation and the build reports in from wherever it landed. + // scheduleDeferredCancelFinalize (below, after the lock is released) + // decides the real outcome after a grace period. + entryData.setQueueLeft(true); + scheduleFinalizeCheck.set(true); + } else if (entryData.getBuildId() == null) { + // No prior cancellation intent AND build has not started anywhere. + // This is a potential load-balanced queue move (build will restart + // on another instance) or a direct Queue.doCancelItem before start. + // Mark queueLeft=true but do NOT set buildCompleted=true so the + // IMap entry is preserved for cross-instance PS2-aborts-PS1 scenarios. + // NOTE: if buildId IS already set, started() already ran on another + // instance — leave the entry completely untouched so it stays visible + // to cancelOutdatedBuilds on that instance. + entryData.setQueueLeft(true); + logger.info("Marking queueLeft (pre-cancellation-intent queue exit, no " + + "buildId yet) for project={} event={} - possible load-balanced " + + "relocation with no started() call yet", projectFullName, key); + } else { + logger.debug("cancelled() called after started() for project={} event={}: " + + "build already running (buildId={}), ignoring late onLeft.", + projectFullName, key, entryData.getBuildId()); + } + modified = true; } else { - logger.debug("cancelled() called after started() for project={} event={}: " - + "build already running (buildId={}), ignoring late onLeft.", + logger.debug("Skipping cancelled() for project={} event={}: " + + "already completed, buildId={}.", projectFullName, key, entryData.getBuildId()); } - modified = true; - } else { - logger.debug("Skipping cancelled() for project={} event={}: " - + "already completed, buildId={}.", - projectFullName, key, entryData.getBuildId()); + break; } - break; } } + if (!found) { + logger.debug("cancelled() called for untracked project={} event={}: skipping.", + projectFullName, key); + } + if (modified) { + map.put(key, data); + } + logger.trace("Cancelled event stored in distributed memory: {}", key); + } catch (Exception e) { + logger.error("Failed to mark cancelled in distributed memory: project={}, event={}", + projectFullName, key, e); } - if (!found) { - logger.debug("cancelled() called for untracked project={} event={}: skipping.", - projectFullName, key); - } - if (modified) { - map.put(key, data); - } - logger.trace("Cancelled event stored in distributed memory: {}", key); - } catch (Exception e) { - logger.error("Failed to mark cancelled in distributed memory: project={}, event={}", - projectFullName, key, e); - } finally { - map.unlock(key); - } + }).onFailure(() -> logger.error( + "Could not acquire distributed lock for key {} within {}s - skipping cancelled()", + key, LOCK_ACQUIRE_TIMEOUT_SECONDS)); - if (scheduleFinalizeCheck) { + if (scheduleFinalizeCheck.get()) { scheduleDeferredCancelFinalize(event, project, key, projectFullName); } } @@ -812,48 +856,44 @@ private void scheduleDeferredCancelFinalize( return; } Timer.get().schedule(() -> { - if (!tryLockWithTimeout(map, key)) { - logger.error("Could not acquire distributed lock for key {} within {}s - skipping " - + "deferred cancel-finalize check", key, LOCK_ACQUIRE_TIMEOUT_SECONDS); - return; - } - boolean finalized = false; - try { - MemoryImprintData data = map.get(key); - if (data == null || data.getEntries() == null) { - return; - } - for (EntryData entryData : data.getEntries()) { - if (projectFullName.equals(entryData.getProjectFullName())) { - if (entryData.isCancelling() && !entryData.isBuildCompleted() - && entryData.getBuildId() == null) { - logger.info("Finalizing deferred cancellation for project={} event={}: " - + "no started() call arrived within {}s grace period - genuine cancel.", - projectFullName, key, CANCEL_FINALIZE_GRACE_SECONDS); - entryData.setCancelled(true); - entryData.setCancelling(false); - entryData.setCompletedTimestamp(System.currentTimeMillis()); - entryData.setBuildCompleted(true); - map.put(key, data); - finalized = true; - } else { - logger.debug("Deferred cancel-finalize check for project={} event={}: " - + "buildId={} isCancelling={} isBuildCompleted={} - a build " - + "reported in elsewhere or already completed; treating as a " - + "relocation, not a genuine cancellation.", - projectFullName, key, entryData.getBuildId(), entryData.isCancelling(), - entryData.isBuildCompleted()); + AtomicBoolean finalized = new AtomicBoolean(false); + withLock(map, key, () -> { + try { + MemoryImprintData data = map.get(key); + if (data == null || data.getEntries() == null) { + return; + } + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + if (entryData.isCancelling() && !entryData.isBuildCompleted() + && entryData.getBuildId() == null) { + logger.info("Finalizing deferred cancellation for project={} event={}: " + + "no started() call arrived within {}s grace period - genuine cancel.", + projectFullName, key, CANCEL_FINALIZE_GRACE_SECONDS); + entryData.setCancelled(true); + entryData.setCancelling(false); + entryData.setCompletedTimestamp(System.currentTimeMillis()); + entryData.setBuildCompleted(true); + map.put(key, data); + finalized.set(true); + } else { + logger.debug("Deferred cancel-finalize check for project={} event={}: " + + "buildId={} isCancelling={} isBuildCompleted={} - a build " + + "reported in elsewhere or already completed; treating as a " + + "relocation, not a genuine cancellation.", + projectFullName, key, entryData.getBuildId(), entryData.isCancelling(), + entryData.isBuildCompleted()); + } + break; } - break; } + } catch (Exception e) { + logger.error("Failed deferred cancel-finalize check: project={}, event={}", + projectFullName, key, e); } - } catch (Exception e) { - logger.error("Failed deferred cancel-finalize check: project={}, event={}", projectFullName, key, e); - return; - } finally { - map.unlock(key); - } - if (finalized) { + }).onFailure(() -> logger.error("Could not acquire distributed lock for key {} within {}s - skipping " + + "deferred cancel-finalize check", key, LOCK_ACQUIRE_TIMEOUT_SECONDS)); + if (finalized.get()) { // The original GerritQueueListener#onLeft call that led here checked // isAllBuildsCompleted() synchronously, before this deferred finalize ran, so it // saw "not yet complete" and skipped the Gerrit-feedback/forget step. Re-invoke it @@ -879,40 +919,37 @@ public synchronized void setCancelling(@NonNull GerritTriggeredEvent event, @Non String projectFullName = project.getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). - if (!tryLockWithTimeout(map, key)) { - logger.error("Could not acquire distributed lock for key {} within {}s - skipping setCancelling()", - key, LOCK_ACQUIRE_TIMEOUT_SECONDS); - return; - } - try { - MemoryImprintData data = map.get(key); - if (data != null && data.getEntries() != null) { - boolean updated = false; - for (EntryData entryData : data.getEntries()) { - if (projectFullName.equals(entryData.getProjectFullName())) { - // Not gated on !isQueueLeft(): an ambiguous queue exit (possible - // load-balanced relocation) is not proof this entry is done, and must - // remain eligible to be marked cancelling so a relocated-then-started - // build still gets cross-replica-aborted (see BuildMemory's identical - // reasoning in cancelOutdatedEvents()). - if (!entryData.isBuildCompleted() && !entryData.isCancelling() - && !entryData.isCancelled()) { - entryData.setCancelling(true); - updated = true; + withLock(map, key, () -> { + try { + MemoryImprintData data = map.get(key); + if (data != null && data.getEntries() != null) { + boolean updated = false; + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + // Not gated on !isQueueLeft(): an ambiguous queue exit (possible + // load-balanced relocation) is not proof this entry is done, and must + // remain eligible to be marked cancelling so a relocated-then-started + // build still gets cross-replica-aborted (see BuildMemory's identical + // reasoning in cancelOutdatedEvents()). + if (!entryData.isBuildCompleted() && !entryData.isCancelling() + && !entryData.isCancelled()) { + entryData.setCancelling(true); + updated = true; + } } } + if (updated) { + map.put(key, data); + } } - if (updated) { - map.put(key, data); - } + logger.trace("Cancelling flag set in distributed memory for event: {}", key); + } catch (Exception e) { + logger.error("Failed to set cancelling flag in distributed memory: project={}, event={}", + projectFullName, key, e); } - logger.trace("Cancelling flag set in distributed memory for event: {}", key); - } catch (Exception e) { - logger.error("Failed to set cancelling flag in distributed memory: project={}, event={}", - projectFullName, key, e); - } finally { - map.unlock(key); - } + }).onFailure(() -> logger.error( + "Could not acquire distributed lock for key {} within {}s - skipping setCancelling()", + key, LOCK_ACQUIRE_TIMEOUT_SECONDS)); } @Override @@ -991,36 +1028,33 @@ public synchronized void removeProject(@NonNull Job project) { java.util.Set keys = new java.util.HashSet<>(map.keySet()); for (String key : keys) { - if (!tryLockWithTimeout(map, key)) { - logger.error("Could not acquire distributed lock for key {} within {}s - skipping removeProject() entry", - key, LOCK_ACQUIRE_TIMEOUT_SECONDS); - continue; - } - try { - MemoryImprintData data = map.get(key); - if (data == null || data.getEntries() == null) { - continue; - } - boolean removed = data.getEntries().removeIf( - entryData -> projectFullName.equals(entryData.getProjectFullName())); - if (removed) { - if (data.getEntries().isEmpty()) { - map.delete(key); - logger.trace("Removed empty entry for project {} from distributed memory: {}", - projectFullName, key); - } else { - map.put(key, data); - logger.trace("Removed project {} from distributed memory entry: {}", - projectFullName, key); + withLock(map, key, () -> { + try { + MemoryImprintData data = map.get(key); + if (data == null || data.getEntries() == null) { + return; } + boolean removed = data.getEntries().removeIf( + entryData -> projectFullName.equals(entryData.getProjectFullName())); + if (removed) { + if (data.getEntries().isEmpty()) { + map.delete(key); + logger.trace("Removed empty entry for project {} from distributed memory: {}", + projectFullName, key); + } else { + map.put(key, data); + logger.trace("Removed project {} from distributed memory entry: {}", + projectFullName, key); + } + } + } catch (Exception e) { + logger.error("Failed to remove project from distributed memory entry: project={}, key={}", + projectFullName, key, e); + // Continue processing other keys } - } catch (Exception e) { - logger.error("Failed to remove project from distributed memory entry: project={}, key={}", - projectFullName, key, e); - // Continue processing other keys - } finally { - map.unlock(key); - } + }).onFailure(() -> logger.error( + "Could not acquire distributed lock for key {} within {}s - skipping removeProject() entry", + key, LOCK_ACQUIRE_TIMEOUT_SECONDS)); } } @@ -1138,37 +1172,34 @@ public void setEntryCustomUrl(@NonNull GerritTriggeredEvent event, @NonNull Run String projectFullName = r.getParent().getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). - if (!tryLockWithTimeout(map, key)) { - logger.error("Could not acquire distributed lock for key {} within {}s - skipping setEntryCustomUrl()", - key, LOCK_ACQUIRE_TIMEOUT_SECONDS); - return; - } - try { - MemoryImprintData data = map.get(key); - if (data == null || data.getEntries() == null) { - logger.warn("Could not set custom URL - event not found: {}", event); - return; - } - boolean found = false; - for (EntryData entryData : data.getEntries()) { - if (projectFullName.equals(entryData.getProjectFullName())) { - entryData.setCustomUrl(customUrl); - found = true; - break; + withLock(map, key, () -> { + try { + MemoryImprintData data = map.get(key); + if (data == null || data.getEntries() == null) { + logger.warn("Could not set custom URL - event not found: {}", event); + return; } + boolean found = false; + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + entryData.setCustomUrl(customUrl); + found = true; + break; + } + } + if (found) { + map.put(key, data); + logger.trace("Recording custom URL for {}: {}", event, customUrl); + } else { + logger.warn("Could not set custom URL - event not found: {}", event); + } + } catch (Exception e) { + logger.error("Failed to set custom URL in distributed memory: project={}, event={}, url={}", + projectFullName, key, customUrl, e); } - if (found) { - map.put(key, data); - logger.trace("Recording custom URL for {}: {}", event, customUrl); - } else { - logger.warn("Could not set custom URL - event not found: {}", event); - } - } catch (Exception e) { - logger.error("Failed to set custom URL in distributed memory: project={}, event={}, url={}", - projectFullName, key, customUrl, e); - } finally { - map.unlock(key); - } + }).onFailure(() -> logger.error( + "Could not acquire distributed lock for key {} within {}s - skipping setEntryCustomUrl()", + key, LOCK_ACQUIRE_TIMEOUT_SECONDS)); } @Override @@ -1184,37 +1215,34 @@ public void setEntryUnsuccessfulMessage(@NonNull GerritTriggeredEvent event, @No String projectFullName = r.getParent().getFullName(); // ATOMIC OPERATION - Distributed lock. EntryProcessor not used (ClassNotFoundException in client mode). - if (!tryLockWithTimeout(map, key)) { - logger.error("Could not acquire distributed lock for key {} within {}s" - + " - skipping setEntryUnsuccessfulMessage()", key, LOCK_ACQUIRE_TIMEOUT_SECONDS); - return; - } - try { - MemoryImprintData data = map.get(key); - if (data == null || data.getEntries() == null) { - logger.warn("Could not set unsuccessful message - event not found: {}", event); - return; - } - boolean found = false; - for (EntryData entryData : data.getEntries()) { - if (projectFullName.equals(entryData.getProjectFullName())) { - entryData.setUnsuccessfulMessage(unsuccessfulMessage); - found = true; - break; + withLock(map, key, () -> { + try { + MemoryImprintData data = map.get(key); + if (data == null || data.getEntries() == null) { + logger.warn("Could not set unsuccessful message - event not found: {}", event); + return; } + boolean found = false; + for (EntryData entryData : data.getEntries()) { + if (projectFullName.equals(entryData.getProjectFullName())) { + entryData.setUnsuccessfulMessage(unsuccessfulMessage); + found = true; + break; + } + } + if (found) { + map.put(key, data); + logger.trace("Recording unsuccessful message for {}: {}", event, unsuccessfulMessage); + } else { + logger.warn("Could not set unsuccessful message - event not found: {}", event); + } + } catch (Exception e) { + logger.error("Failed to set unsuccessful message in distributed memory:" + + " project={}, event={}, message={}", + projectFullName, key, unsuccessfulMessage, e); } - if (found) { - map.put(key, data); - logger.trace("Recording unsuccessful message for {}: {}", event, unsuccessfulMessage); - } else { - logger.warn("Could not set unsuccessful message - event not found: {}", event); - } - } catch (Exception e) { - logger.error("Failed to set unsuccessful message in distributed memory: project={}, event={}, message={}", - projectFullName, key, unsuccessfulMessage, e); - } finally { - map.unlock(key); - } + }).onFailure(() -> logger.error("Could not acquire distributed lock for key {} within {}s" + + " - skipping setEntryUnsuccessfulMessage()", key, LOCK_ACQUIRE_TIMEOUT_SECONDS)); } @Override From 892f175c08f35863f312f3b29023d21a73fbc641 Mon Sep 17 00:00:00 2001 From: sboardwell Date: Mon, 27 Jul 2026 08:24:16 +0200 Subject: [PATCH 84/87] Fix testAbortedPipelineReturnsFalse to assert post-abort state The assertion ran before interrupt()/waitForCompletion(), so it only duplicated testPipelineBlockedAtSemaphoreReturnsFalse's pre-abort check and never verified the aborted/completed state its name and javadoc claimed to cover. --- .../hudsontrigger/PipelineAbortHelperTest.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/PipelineAbortHelperTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/PipelineAbortHelperTest.java index 75e573743..ca3a6e320 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/PipelineAbortHelperTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/hudsontrigger/PipelineAbortHelperTest.java @@ -82,8 +82,9 @@ public void testPipelineBlockedAtSemaphoreReturnsFalse() throws Exception { } /** - * A Pipeline build that has been interrupted after it started should still - * report false — it is past initialisation, so delivery was correct. + * A Pipeline build that has been interrupted and completed should still report + * false — the FlowExecution remains attached after the run finishes, so it + * remains safe (and correct) to report as started. */ @Test public void testAbortedPipelineReturnsFalse() throws Exception { @@ -95,10 +96,10 @@ public void testAbortedPipelineReturnsFalse() throws Exception { WorkflowRun run = job.scheduleBuild2(0).waitForStart(); SemaphoreStep.waitForStart("wait-abort/1", run); - // Abort while it's at the semaphore (CPS has started) - assertFalse(PipelineAbortHelper.isPipelineNotYetStarted(run)); - run.getExecutor().interrupt(Result.ABORTED); jenkins.waitForCompletion(run); + + assertFalse("Aborted, completed pipeline should still report CPS started", + PipelineAbortHelper.isPipelineNotYetStarted(run)); } } From ffec86191d4171ce2f5644dcc14f579bbb00f4c5 Mon Sep 17 00:00:00 2001 From: sboardwell Date: Mon, 27 Jul 2026 08:28:28 +0200 Subject: [PATCH 85/87] Corrected stale Javadoc to remove == comparison --- .../trigger/gerritnotifier/model/BuildMemory.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java index 53605f932..611e692f2 100644 --- a/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java +++ b/src/main/java/com/sonyericsson/hudson/plugins/gerrit/trigger/gerritnotifier/model/BuildMemory.java @@ -673,12 +673,14 @@ private void cancelMatchingJobs( * Ported from RunningJobs.checkCausedByGerrit(). *

                    * Important: Event comparison is delegated to the storage implementation - * via {@link BuildMemoryStorage#eventsMatch(GerritTriggeredEvent, GerritTriggeredEvent)}. - * This respects the abstraction boundary: + * via {@link BuildMemoryStorage#eventsMatch(GerritTriggeredEvent, GerritTriggeredEvent)}, + * which always uses logical equality rather than instance identity ({@code ==}) - events + * can be deserialized (e.g. {@code GerritCause}'s event loaded from disk, or from Hazelcast + * in distributed mode), so two logically-equal events are not guaranteed to be the same + * instance: *

                      - *
                    • Local mode: Uses identity comparison (==)
                    • - *
                    • Distributed mode: Uses logical comparison via EventIdGenerator - * since events may be deserialized
                    • + *
                    • Local mode: Uses {@link Object#equals(Object)}
                    • + *
                    • Distributed mode: Uses logical comparison via EventIdGenerator
                    • *
                    * * @param event the event to check for From 5887ec3333aa0dbe93ede5a52851217f521f99eb Mon Sep 17 00:00:00 2001 From: sboardwell Date: Mon, 27 Jul 2026 10:05:22 +0200 Subject: [PATCH 86/87] Add default-run Hazelcast coordination smoke test The test-hazelcast Maven profile is opt-in only, and HazelcastTestRule uses Assume.assumeTrue to skip BuildCancellationHazelcastIntegrationTest whenever it isn't active. As a result, a normal `mvn test` run never exercises Hazelcast coordination mode - the test class is picked up by surefire but every test in it is silently skipped. HazelcastCoordinationSmokeTest closes that gap without requiring the profile. It starts its own embedded Hazelcast server and sets the coordination-mode/client-address system properties from a @ClassRule, which JUnit4 guarantees runs before JenkinsRule regardless of field order (the same ordering problem HazelcastTestRule sidesteps by requiring the properties to be set before the JVM starts via the profile). It then asserts CoordinationModeFactory actually selected HazelcastBuildMemoryStorage - not a silent fallback to local mode - and triggers a real Gerrit patchset event end-to-end, verifying the build completes successfully over the live Hazelcast client/server connection. This exercises build-memory storage, event claiming, and notification claiming, giving CI a fast signal that future changes haven't broken Hazelcast coordination, without the cost/flakiness surface of the full cancellation-race suite, which remains opt-in behind -Ptest-hazelcast. --- .../HazelcastCoordinationSmokeTest.java | 221 ++++++++++++++++++ .../common/gerrit-trigger.xml | 27 +++ 2 files changed, 248 insertions(+) create mode 100644 src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationSmokeTest.java create mode 100644 src/test/resources/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationSmokeTest/common/gerrit-trigger.xml diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationSmokeTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationSmokeTest.java new file mode 100644 index 000000000..18cfaf220 --- /dev/null +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationSmokeTest.java @@ -0,0 +1,221 @@ +/* + * The MIT License + * + * Copyright 2026 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 com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.hazelcast; + +import com.sonyericsson.hudson.plugins.gerrit.trigger.GerritServer; +import com.sonyericsson.hudson.plugins.gerrit.trigger.PluginImpl; +import com.sonyericsson.hudson.plugins.gerrit.trigger.coordination.CoordinationModeFactory; +import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.GerritTrigger; +import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.Branch; +import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.CompareType; +import com.sonyericsson.hudson.plugins.gerrit.trigger.hudsontrigger.data.GerritProject; +import com.sonyericsson.hudson.plugins.gerrit.trigger.mock.Setup; +import com.sonyericsson.hudson.plugins.gerrit.trigger.mock.TestUtils; +import com.sonymobile.tools.gerrit.gerritevents.dto.events.PatchsetCreated; +import com.sonymobile.tools.gerrit.gerritevents.mock.SshdServerMock; + +import java.util.Collections; +import hudson.model.FreeStyleBuild; +import hudson.model.FreeStyleProject; +import org.apache.sshd.server.SshServer; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExternalResource; +import org.jvnet.hudson.test.BuildWatcher; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.recipes.LocalData; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static com.sonymobile.tools.gerrit.gerritevents.mock.SshdServerMock.GERRIT_STREAM_EVENTS; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * Smoke test that exercises Hazelcast coordination mode during the default + * {@code mvn test} run, without requiring the {@code -Ptest-hazelcast} profile. + *

                    + * {@link BuildCancellationHazelcastIntegrationTest} and its {@link HazelcastTestRule} only run + * under that profile, because the coordination mode system property has to be set before the JVM + * starts (see {@link HazelcastTestRule} for why setting it from an instance {@code @Rule} is too + * late). This test avoids that requirement by starting its own embedded Hazelcast server and + * setting the coordination properties from a {@code @ClassRule}, which is guaranteed by JUnit4 to + * run before any instance {@code @Rule} - including {@link JenkinsRule} - regardless of field + * declaration order. + *

                    + * The point of this test is narrow: give normal CI a fast, self-contained signal that Hazelcast + * coordination mode still wires up and can trigger and complete a build end-to-end. It is not a + * replacement for the broader cancellation-race coverage in + * {@link BuildCancellationHazelcastIntegrationTest}, which remains opt-in due to its cost and + * flakiness surface. + */ +public class HazelcastCoordinationSmokeTest { + + private static final Logger logger = LoggerFactory.getLogger(HazelcastCoordinationSmokeTest.class); + + private static final String COORDINATION_MODE_PROPERTY = "gerrit.trigger.coordination.mode"; + private static final String HAZELCAST_MODE = "hazelcast"; + private static final int SERVER_WAIT = 2000; + private static final int BUILD_TIMEOUT = 30000; + + /** + * Starts an embedded Hazelcast server and points the coordination system properties at it + * before {@link JenkinsRule} boots Jenkins - see the class Javadoc for why a {@code @ClassRule} + * is required here rather than {@code @Before}/{@code @Rule}. + */ + //CS IGNORE VisibilityModifier FOR NEXT 2 LINES. REASON: JUnit ClassRule. + @ClassRule + public static final ExternalResource HAZELCAST_SERVER = new ExternalResource() { + + private String originalMode; + private String originalAddresses; + + @Override + protected void before() { + originalMode = System.getProperty(COORDINATION_MODE_PROPERTY); + originalAddresses = System.getProperty(HazelcastConfig.CLIENT_ADDRESSES_PROPERTY); + + EmbeddedHazelcastTestServer.start(); + System.setProperty(COORDINATION_MODE_PROPERTY, HAZELCAST_MODE); + System.setProperty(HazelcastConfig.CLIENT_ADDRESSES_PROPERTY, + "localhost:" + EmbeddedHazelcastTestServer.getPort()); + logger.info("Smoke test: embedded Hazelcast server ready on port {}", + EmbeddedHazelcastTestServer.getPort()); + } + + @Override + protected void after() { + // Jenkins (via JenkinsRule teardown, which nests inside this ClassRule and has + // already completed by the time this runs) shuts down every CoordinationModeProvider + // - including HazelcastCoordinationProvider, which calls HazelcastManager.shutdown() + // itself. Calling it again here would race the plugin's own shutdown and intermittently + // throw HazelcastClientNotActiveException, so only the server this class started is + // stopped here. + EmbeddedHazelcastTestServer.stop(); + restoreProperty(COORDINATION_MODE_PROPERTY, originalMode); + restoreProperty(HazelcastConfig.CLIENT_ADDRESSES_PROPERTY, originalAddresses); + } + + private void restoreProperty(String key, String value) { + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } + }; + + /** + * An instance of Jenkins Rule. + */ + //CS IGNORE VisibilityModifier FOR NEXT 2 LINES. REASON: JenkinsRule. + @Rule + public final JenkinsRule jenkins = new JenkinsRule(); + + /** + * Outputs build logs to std out. + */ + //CS IGNORE VisibilityModifier FOR NEXT 2 LINES. REASON: JenkinsRule. + @Rule + public final BuildWatcher watcher = new BuildWatcher(); + + private SshServer sshd; + private SshdServerMock serverMock; + private GerritServer gerritServer; + + /** + * Sets up the SSH server mock before each test. + * + * @throws Exception if setup fails + */ + @Before + public void setUp() throws Exception { + SshdServerMock.generateKeyPair(); + serverMock = new SshdServerMock(); + sshd = SshdServerMock.startServer(serverMock); + serverMock.returnCommandFor("gerrit ls-projects", SshdServerMock.EofCommandMock.class); + serverMock.returnCommandFor(GERRIT_STREAM_EVENTS, SshdServerMock.CommandMock.class); + serverMock.returnCommandFor("gerrit review.*", SshdServerMock.EofCommandMock.class); + serverMock.returnCommandFor("gerrit version", SshdServerMock.SendVersionCommand.class); + gerritServer = PluginImpl.getFirstServer_(); + if (gerritServer != null) { + SshdServerMock.configureFor(sshd, gerritServer, true); + } + } + + /** + * Tears down the SSH server and clears Hazelcast state. + * + * @throws Exception if teardown fails + */ + @After + public void tearDown() throws Exception { + HazelcastTestHelper.clearAllMaps(); + if (sshd != null) { + sshd.stop(true); + sshd = null; + } + } + + /** + * Verifies that Hazelcast coordination mode is actually active, and that a Gerrit event + * triggers and completes a build end-to-end through Hazelcast-backed build memory, event + * claiming, and notification claiming - not just the mode selection. + * + * @throws Exception if unexpected errors appear. + */ + @Test + @LocalData("common") + public void testHazelcastCoordinationModeTriggersAndCompletesBuild() throws Exception { + CoordinationModeFactory factory = CoordinationModeFactory.get(); + String storageClass = factory.getStorage().getClass().getSimpleName(); + assertEquals("Expected Hazelcast storage - falling back to local mode would defeat " + + "the point of this smoke test", "HazelcastBuildMemoryStorage", storageClass); + assertNotNull("Expected a selected coordination mode", factory.getSelectedMode()); + assertEquals("Expected Hazelcast mode", "Hazelcast (Distributed)", + factory.getSelectedMode().getModeName()); + + FreeStyleProject project = jenkins.createFreeStyleProject(); + GerritTrigger trigger = Setup.createDefaultTrigger(project); + trigger.setGerritProjects(Collections.singletonList( + new GerritProject(CompareType.ANT, "**", + Collections.singletonList(new Branch(CompareType.ANT, "**")), + null, null, null, false))); + project.addTrigger(trigger); + trigger.start(project, false); + + serverMock.waitForCommand(GERRIT_STREAM_EVENTS, SERVER_WAIT); + + PatchsetCreated patchset = Setup.createPatchsetCreated(); + gerritServer.triggerEvent(patchset); + + TestUtils.waitForBuilds(project, 1, BUILD_TIMEOUT); + FreeStyleBuild build = project.getLastBuild(); + assertNotNull("Build should have been triggered via Hazelcast-backed coordination", build); + jenkins.assertBuildStatusSuccess(build); + } +} diff --git a/src/test/resources/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationSmokeTest/common/gerrit-trigger.xml b/src/test/resources/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationSmokeTest/common/gerrit-trigger.xml new file mode 100644 index 000000000..48b3ea51b --- /dev/null +++ b/src/test/resources/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationSmokeTest/common/gerrit-trigger.xml @@ -0,0 +1,27 @@ + + + + 127.0.0.1 + 29418 + jenkins + /tmp/jenkins-testkey + + 3 + gerrit review <CHANGE>,<PATCHSET> --message 'Build Successful' --verified <VERIFIED> + gerrit review <CHANGE>,<PATCHSET> --message 'Build Unstable' --verified <VERIFIED> + gerrit review <CHANGE>,<PATCHSET> --message 'Build Failed' --verified <VERIFIED> + gerrit review <CHANGE>,<PATCHSET> --message 'Build Started' --verified <VERIFIED> + http://localhost/ + 0 + 0 + 1 + 0 + -1 + 0 + -1 + 0 + true + 1 + 1 + + From 6c7fba2f6e0b0c02dd4115bacded293c43ded662 Mon Sep 17 00:00:00 2001 From: Robert Sandell Date: Mon, 27 Jul 2026 13:12:55 +0200 Subject: [PATCH 87/87] Raise smoke test stream-events wait to 20s for busy CI agents HazelcastCoordinationSmokeTest copied SERVER_WAIT=2000 from the BuildCancellation tests, but unlike those it runs a live Hazelcast client in the same JVM. On a resource-constrained agent that extra CPU contention slows the Gerrit SSH handshake so 'gerrit stream-events' arrives after the 2s window - the connection is healthy and the command does fire, just late (it missed by ~50ms on CI). Reproduced locally under single-core contention: fails at 2s, passes at 20s. 20s matches GerritServerSshServerTest and GerritTriggerApiTest; waitForCommand returns as soon as the command appears, so the larger ceiling costs nothing on a fast machine. --- .../coordination/hazelcast/HazelcastCoordinationSmokeTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationSmokeTest.java b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationSmokeTest.java index 18cfaf220..1ddafdaba 100644 --- a/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationSmokeTest.java +++ b/src/test/java/com/sonyericsson/hudson/plugins/gerrit/trigger/coordination/hazelcast/HazelcastCoordinationSmokeTest.java @@ -79,7 +79,8 @@ public class HazelcastCoordinationSmokeTest { private static final String COORDINATION_MODE_PROPERTY = "gerrit.trigger.coordination.mode"; private static final String HAZELCAST_MODE = "hazelcast"; - private static final int SERVER_WAIT = 2000; + // Generous: the in-JVM Hazelcast client's CPU contention slows the SSH handshake on busy agents. + private static final int SERVER_WAIT = 20000; private static final int BUILD_TIMEOUT = 30000; /**