Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,15 @@ void mapClusterToSituations(Cluster<AlarmInSpaceTime> clusterOfAlarms, TickConte
situationBuilders.size());
situationBuilders.forEach(situationBuilder -> {
situationBuilder.setDiagnosticText(getDiagnosticTextForSituation(situationBuilder.build()));
situationBuilder.setEngineParameter(Thread.currentThread().getName().substring(20));
// Driver names its tick thread "ALEC Driver Tick -- <params>" (prefix is 20 chars).
// In tests or non-driver contexts the prefix may be absent; fall back to the
// whole thread name rather than throwing StringIndexOutOfBoundsException.
String threadName = Thread.currentThread().getName();
String enginePrefix = "ALEC Driver Tick -- ";
situationBuilder.setEngineParameter(
threadName.startsWith(enginePrefix)
? threadName.substring(enginePrefix.length())
: threadName);
});
LOG.debug("{}: Done generating diagnostic texts.", context.getTimestampInMillis());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,25 @@
import org.opennms.alec.engine.cluster.SpatialDistanceCalculator;

public class AlarmInSpaceAndTimeDistanceMeasureFactory implements DistanceMeasureFactory {

private double noPathDistance = AlarmInSpaceTimeDistanceMeasure.DEFAULT_NO_PATH_DISTANCE;

@Override
public String getName() {
return "alarminspaceandtimedistance";
}

@Override
public DistanceMeasure createDistanceMeasure(Object spatialDistanceCalculator, double alpha, double beta) {
return new AlarmInSpaceTimeDistanceMeasure((SpatialDistanceCalculator) spatialDistanceCalculator, alpha, beta);
return new AlarmInSpaceTimeDistanceMeasure(
(SpatialDistanceCalculator) spatialDistanceCalculator, alpha, beta, noPathDistance);
}

public double getNoPathDistance() {
return noPathDistance;
}

public void setNoPathDistance(double noPathDistance) {
this.noPathDistance = noPathDistance;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,22 @@

public class AlarmInSpaceTimeDistanceMeasure implements DistanceMeasure {
public static final double DEFAULT_EPSILON = 100d;
public static final double DEFAULT_NO_PATH_DISTANCE = 100d;

private final SpatialDistanceCalculator spatialDistanceCalculator;
private final double alpha;
private final double beta;
private final double noPathDistance;

public AlarmInSpaceTimeDistanceMeasure(SpatialDistanceCalculator SpatialDistanceCalculator, double alpha, double beta) {
this(SpatialDistanceCalculator, alpha, beta, DEFAULT_NO_PATH_DISTANCE);
}

public AlarmInSpaceTimeDistanceMeasure(SpatialDistanceCalculator SpatialDistanceCalculator, double alpha, double beta, double noPathDistance) {
this.spatialDistanceCalculator = Objects.requireNonNull(SpatialDistanceCalculator);
this.alpha = alpha;
this.beta = beta;
this.noPathDistance = noPathDistance;
}

@Override
Expand All @@ -60,9 +67,13 @@ public double compute(double[] a, double[] b) throws DimensionMismatchException
double spatialDistance = 0;
if (vertexIdA != vertexIdB) {
spatialDistance = spatialDistanceCalculator.getSpatialDistanceBetween(vertexIdA, vertexIdB);
if (spatialDistance == 0) {
// No path
spatialDistance = Integer.MAX_VALUE;
if (spatialDistance == 0 || spatialDistance >= Integer.MAX_VALUE) {
// No path between the two vertices in the topology graph.
// SpatialDistanceCalculator may return either 0 or Integer.MAX_VALUE
// for the no-path case (depending on caller); we treat both the same.
// The substituted value bounds how punishing the cross-device case is.
// Configurable via DBScanEngineFactory#setNoPathDistance (ALEC-297).
spatialDistance = noPathDistance;
}
}

Expand Down Expand Up @@ -90,6 +101,10 @@ public double getBeta() {
return beta;
}

public double getNoPathDistance() {
return noPathDistance;
}

@Override
public String getName() {
return "alarminspacetime";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ public String getNameConf() {
.add("epsilon=" + epsilon)
.add("alpha=" + alpha)
.add("beta=" + beta)
.add("noPathDistance=" + getNoPathDistance())
.add("distanceMeasure='" + distanceMeasureFactoryName + "'")
.toString();
}
Expand All @@ -96,8 +97,8 @@ public EngineFactory getEngineFactory() {

@Override
public String getParameters() {
return String.format("engine: %s, alpha: %s, beta: %s, epsilon: %s, distanceMeasure: %s",
getName(), getAlpha(), getBeta(), getEpsilon(), getDistanceMeasureFactoryName());
return String.format("engine: %s, alpha: %s, beta: %s, epsilon: %s, noPathDistance: %s, distanceMeasure: %s",
getName(), getAlpha(), getBeta(), getEpsilon(), getNoPathDistance(), getDistanceMeasureFactoryName());
}

public double getEpsilon() {
Expand Down Expand Up @@ -131,4 +132,12 @@ public void setDistanceMeasureFactoryName(String distanceMeasureFactoryName) {
public String getDistanceMeasureFactoryName() {
return distanceMeasureFactoryName;
}

public double getNoPathDistance() {
return alarmInSpaceAndTimeDistanceMeasureFactory.getNoPathDistance();
}

public void setNoPathDistance(double noPathDistance) {
alarmInSpaceAndTimeDistanceMeasureFactory.setNoPathDistance(noPathDistance);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
<cm:property name="epsilon" value="100"/>
<cm:property name="alpha" value="144.47117699"/>
<cm:property name="beta" value="0.55257784"/>
<cm:property name="noPathDistance" value="100"/>
<cm:property name="distanceMeasure" value="alarmInSpaceAndTimeDistanceMeasureFactory"/>
</cm:default-properties>
</cm:property-placeholder>

<bean id="hellingerDistanceMeasureFactory" class="org.opennms.alec.engine.dbscan.HellingerDistanceMeasureFactory"/>
<bean id="alarmInSpaceAndTimeDistanceMeasureFactory" class="org.opennms.alec.engine.dbscan.AlarmInSpaceAndTimeDistanceMeasureFactory"/>
<bean id="alarmInSpaceAndTimeDistanceMeasureFactory" class="org.opennms.alec.engine.dbscan.AlarmInSpaceAndTimeDistanceMeasureFactory">
<property name="noPathDistance" value="${noPathDistance}"/>
</bean>

<!-- Create and expose the engine factory -->
<service interface="org.opennms.alec.engine.api.EngineFactory" ranking="20">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@

package org.opennms.alec.engine.dbscan;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.lessThan;
import static org.hamcrest.number.IsCloseTo.closeTo;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.opennms.alec.datasource.api.InventoryObject.DEFAULT_WEIGHT;

import java.util.concurrent.TimeUnit;
Expand Down Expand Up @@ -64,4 +68,50 @@ public void canEvaluateDistanceFunction() {
final AlarmInSpaceTimeDistanceMeasure alarmInSpaceTimeDistanceMeasure = new AlarmInSpaceTimeDistanceMeasure(clusterEngine, DBScanEngine.DEFAULT_ALPHA, DBScanEngine.DEFAULT_BETA);
return alarmInSpaceTimeDistanceMeasure.compute(0, timeDeltaMs, spatialDistance);
}

@Test
public void noPathBetweenVerticesUsesConfiguredNoPathDistance() {
// ALEC-297: when the spatial calculator returns Integer.MAX_VALUE
// (the no-path sentinel), the measure must substitute the configured
// noPathDistance instead — otherwise no realistic epsilon ever clusters
// alarms across devices.
final AbstractClusterEngine clusterEngine = mock(AbstractClusterEngine.class);
when(clusterEngine.getSpatialDistanceBetween(1L, 2L))
.thenReturn((double) Integer.MAX_VALUE);

final AlarmInSpaceTimeDistanceMeasure measure = new AlarmInSpaceTimeDistanceMeasure(
clusterEngine,
DBScanEngine.DEFAULT_ALPHA,
DBScanEngine.DEFAULT_BETA,
100d);

// a/b structure: [time, vertexId, firstTime]
double distance = measure.compute(new double[]{0d, 1d, 0d}, new double[]{1000d, 2d, 1000d});

// With noPathDistance=100 and 1s time delta, distance ~= 66.
// Critically: < default epsilon (100). Pre-fix this would have been ~10^9.
assertThat(distance, lessThan(AlarmInSpaceTimeDistanceMeasure.DEFAULT_EPSILON));
assertThat(distance, closeTo(65.96d, 1d));
}

@Test
public void noPathBetweenVerticesWithLargePenalty_preservesOldBehaviour() {
// Operators who rely on the pre-fix "cross-device alarms never cluster"
// behaviour can opt back into it by setting noPathDistance to a very large
// value (e.g. Integer.MAX_VALUE).
final AbstractClusterEngine clusterEngine = mock(AbstractClusterEngine.class);
when(clusterEngine.getSpatialDistanceBetween(1L, 2L))
.thenReturn((double) Integer.MAX_VALUE);

final AlarmInSpaceTimeDistanceMeasure measure = new AlarmInSpaceTimeDistanceMeasure(
clusterEngine,
DBScanEngine.DEFAULT_ALPHA,
DBScanEngine.DEFAULT_BETA,
Integer.MAX_VALUE);

double distance = measure.compute(new double[]{0d, 1d, 0d}, new double[]{1000d, 2d, 1000d});

// Distance dwarfs default epsilon (100) — alarms remain unclustered.
assertThat(distance > 1e8, org.hamcrest.Matchers.equalTo(true));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ public class DBScanEnginePerfTest {
@Test
public void canRunDBScanOnLargeGraphs() {
final DBScanEngine dbScanEngine = new DBScanEngine(new MetricRegistry(), AlarmInSpaceTimeDistanceMeasure.DEFAULT_EPSILON, DBScanEngine.DEFAULT_ALPHA, DBScanEngine.DEFAULT_BETA, new AlarmInSpaceAndTimeDistanceMeasureFactory());
// After ALEC-297 the default noPathDistance lets disconnected-IO alarms cluster,
// so this test now produces a situation; register a no-op handler (the other
// tests in this file do the same) instead of letting the engine NPE.
dbScanEngine.registerSituationHandler(mock(SituationHandler.class));
dbScanEngine.init(Collections.emptyList(), Collections.emptyList(), Collections.emptyList(),
Collections.emptyList());
final int K = 500;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*******************************************************************************
* This file is part of OpenNMS(R).
*
* Copyright (C) 2026 The OpenNMS Group, Inc.
* OpenNMS(R) is Copyright (C) 1999-2026 The OpenNMS Group, Inc.
*
* OpenNMS(R) is a registered trademark of The OpenNMS Group, Inc.
*
* OpenNMS(R) is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License,
* or (at your option) any later version.
*
* OpenNMS(R) is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with OpenNMS(R). If not, see:
* http://www.gnu.org/licenses/
*
* For more information contact:
* OpenNMS(R) Licensing <license@opennms.org>
* http://www.opennms.org/
* http://www.opennms.com/
*******************************************************************************/

package org.opennms.alec.engine.itest;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;

import org.junit.Test;
import org.opennms.alec.datasource.api.Alarm;
import org.opennms.alec.datasource.api.Situation;
import org.opennms.alec.datasource.common.ImmutableAlarm;
import org.opennms.alec.driver.test.MockInventoryType;
import org.opennms.alec.driver.test.TestDriver;
import org.opennms.alec.engine.dbscan.AlarmInSpaceAndTimeDistanceMeasureFactory;
import org.opennms.alec.engine.dbscan.AlarmInSpaceTimeDistanceMeasure;
import org.opennms.alec.engine.dbscan.DBScanEngine;
import org.opennms.alec.engine.dbscan.DBScanEngineFactory;

/**
* Verifies the ALEC-297 fix: alarms on different devices with no topology path
* between them now cluster under default DBScan parameters, because the no-path
* spatial distance penalty is configurable (default 100, comparable to typical
* graph diameters) instead of {@link Integer#MAX_VALUE}.
*
* <p>Before ALEC-297 the same scenario required epsilon &gt;= 1e10 to cluster.
*/
public class CrossDeviceCorrelationIT {

private List<Alarm> twoAlarmsOnTwoDevices() {
long t = 60_000L;
return Arrays.asList(
ImmutableAlarm.newBuilder()
.setId("a1")
.setTime(t)
.setInventoryObjectType(MockInventoryType.DEVICE.getType())
.setInventoryObjectId("device-1")
.build(),
ImmutableAlarm.newBuilder()
.setId("a2")
.setTime(t + 1000L)
.setInventoryObjectType(MockInventoryType.DEVICE.getType())
.setInventoryObjectId("device-2")
.build());
}

private TestDriver driverWithEpsilonAndNoPathDistance(double epsilon, double noPathDistance) {
AlarmInSpaceAndTimeDistanceMeasureFactory measureFactory =
new AlarmInSpaceAndTimeDistanceMeasureFactory();
measureFactory.setNoPathDistance(noPathDistance);
DBScanEngineFactory factory = new DBScanEngineFactory(
DBScanEngine.DEFAULT_ALPHA,
DBScanEngine.DEFAULT_BETA,
epsilon,
"alarminspaceandtimedistance",
measureFactory,
Collections.singletonMap("alarminspaceandtimedistance", measureFactory));
return TestDriver.builder()
.withEngineFactory(factory)
.build();
}

@Test
public void crossDeviceAlarmsClusterAtDefaultParameters() {
List<Alarm> alarms = twoAlarmsOnTwoDevices();

List<Situation> situations = driverWithEpsilonAndNoPathDistance(
AlarmInSpaceTimeDistanceMeasure.DEFAULT_EPSILON,
AlarmInSpaceTimeDistanceMeasure.DEFAULT_NO_PATH_DISTANCE).run(alarms);

int alarmsInSituations = situations.stream()
.mapToInt(s -> s.getAlarms().size())
.sum();
assertThat(
"With default DBScan parameters (epsilon=100, noPathDistance=100), "
+ "two alarms ~1s apart on different devices should cluster.",
alarmsInSituations, greaterThanOrEqualTo(2));
}

@Test
public void largeNoPathDistance_preservesPreFixBehaviour() {
List<Alarm> alarms = twoAlarmsOnTwoDevices();

List<Situation> situations = driverWithEpsilonAndNoPathDistance(
AlarmInSpaceTimeDistanceMeasure.DEFAULT_EPSILON,
Integer.MAX_VALUE).run(alarms);

int alarmsInSituations = situations.stream()
.mapToInt(s -> s.getAlarms().size())
.sum();
assertThat(
"With noPathDistance set to Integer.MAX_VALUE (the pre-fix value), "
+ "the alarms should NOT cluster at default epsilon — operators "
+ "who depend on the old behaviour can opt into it explicitly.",
alarmsInSituations, org.hamcrest.Matchers.equalTo(0));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;

@JsonDeserialize(builder = EngineParameterImpl.Builder.class)
@JsonPropertyOrder({"engineName", "distanceMeasureName", "alpha", "beta", "epsilon", "remoteUri", "token", "remote"})
@JsonPropertyOrder({"engineName", "distanceMeasureName", "alpha", "beta", "epsilon", "noPathDistance", "remoteUri", "token", "remote"})
public interface EngineParameter {
Double getAlpha();

Double getBeta();

Double getEpsilon();

Double getNoPathDistance();

String getDistanceMeasureName();

String getEngineName();
Expand Down
Loading
Loading