Skip to content

Commit ba73143

Browse files
committed
feat(alerting): Add OrphanedAlertSweeper and fix JobSweeper to deschedule deleted monitors
Add OrphanedAlertSweeper: a scheduled background job that periodically scans for alerts whose monitors no longer exist in .opendistro-alerting-config. Orphaned alerts are moved to the alert history index with state DELETED. Fix JobSweeper.sweepShard to deschedule monitors that are in the in-memory sweptJobs map but no longer exist in the config index. Previously, the background sweep only handled new/updated jobs and shard ownership changes, relying entirely on postDelete for deletions. If postDelete failed to fire (e.g., due to IndexingOperationListener not triggering), the monitor would remain scheduled indefinitely, producing repeated 'Can't find monitor' errors. Configurable via: - plugins.alerting.orphaned_alert_sweep_period (default: 5m) - plugins.alerting.orphaned_alert_sweep_enabled (default: true) Includes integration tests verifying: - Active orphaned alerts are moved to history as DELETED - Acknowledged orphaned alerts are moved to history as DELETED - Non-orphaned alerts are not affected by the sweeper Signed-off-by: Dennis Toepker <toepkerd@amazon.com>
1 parent 28dcf56 commit ba73143

5 files changed

Lines changed: 388 additions & 2 deletions

File tree

alerting/src/main/kotlin/org/opensearch/alerting/AlertingPlugin.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import org.opensearch.alerting.action.SearchEmailAccountAction
1616
import org.opensearch.alerting.action.SearchEmailGroupAction
1717
import org.opensearch.alerting.alerts.AlertIndices
1818
import org.opensearch.alerting.alerts.AlertIndices.Companion.ALL_ALERT_INDEX_PATTERN
19+
import org.opensearch.alerting.alerts.OrphanedAlertSweeper
1920
import org.opensearch.alerting.comments.CommentsIndices
2021
import org.opensearch.alerting.comments.CommentsIndices.Companion.ALL_COMMENTS_INDEX_PATTERN
2122
import org.opensearch.alerting.core.JobSweeper
@@ -196,6 +197,7 @@ internal class AlertingPlugin : PainlessExtension, ActionPlugin, ScriptPlugin, R
196197
lateinit var runner: MonitorRunnerService
197198
lateinit var scheduler: JobScheduler
198199
lateinit var sweeper: JobSweeper
200+
lateinit var orphanedAlertSweeper: OrphanedAlertSweeper
199201
lateinit var scheduledJobIndices: ScheduledJobIndices
200202
lateinit var commentsIndices: CommentsIndices
201203
lateinit var docLevelMonitorQueries: DocLevelMonitorQueries
@@ -359,6 +361,7 @@ internal class AlertingPlugin : PainlessExtension, ActionPlugin, ScriptPlugin, R
359361
docLevelMonitorQueries = DocLevelMonitorQueries(client, clusterService)
360362
scheduler = JobScheduler(threadPool, runner)
361363
sweeper = JobSweeper(environment.settings(), client, clusterService, threadPool, xContentRegistry, scheduler, ALERTING_JOB_TYPES)
364+
orphanedAlertSweeper = OrphanedAlertSweeper(environment.settings(), client, threadPool, clusterService)
362365
destinationMigrationCoordinator = DestinationMigrationCoordinator(client, clusterService, threadPool, scheduledJobIndices)
363366
this.threadPool = threadPool
364367
this.clusterService = clusterService
@@ -421,7 +424,8 @@ internal class AlertingPlugin : PainlessExtension, ActionPlugin, ScriptPlugin, R
421424
alertService,
422425
triggerService,
423426
sdkClient,
424-
monitorJobPoller
427+
monitorJobPoller,
428+
orphanedAlertSweeper
425429
)
426430
}
427431

@@ -527,7 +531,9 @@ internal class AlertingPlugin : PainlessExtension, ActionPlugin, ScriptPlugin, R
527531
AlertingSettings.JOB_QUEUE_ACCOUNT_PROVIDER_TYPE,
528532
AlertingSettings.TARGET_TYPE_TO_SERVICE_NAME,
529533
AlertingSettings.TENANT_ACCOUNT_ID_HEADER,
530-
AlertingSettings.TENANT_RESOURCE_ID_HEADER
534+
AlertingSettings.TENANT_RESOURCE_ID_HEADER,
535+
AlertingSettings.ORPHANED_ALERT_SWEEP_PERIOD,
536+
AlertingSettings.ORPHANED_ALERT_SWEEP_ENABLED
531537
)
532538
}
533539

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.opensearch.alerting.alerts
7+
8+
import kotlinx.coroutines.CoroutineScope
9+
import kotlinx.coroutines.Dispatchers
10+
import kotlinx.coroutines.launch
11+
import kotlinx.coroutines.withTimeout
12+
import org.apache.logging.log4j.LogManager
13+
import org.opensearch.action.search.SearchRequest
14+
import org.opensearch.action.search.SearchResponse
15+
import org.opensearch.alerting.alerts.AlertIndices.Companion.ALERT_INDEX
16+
import org.opensearch.alerting.opensearchapi.suspendUntil
17+
import org.opensearch.alerting.settings.AlertingSettings
18+
import org.opensearch.cluster.ClusterChangedEvent
19+
import org.opensearch.cluster.ClusterStateListener
20+
import org.opensearch.cluster.service.ClusterService
21+
import org.opensearch.common.settings.Settings
22+
import org.opensearch.commons.alerting.model.Alert
23+
import org.opensearch.commons.alerting.model.ScheduledJob
24+
import org.opensearch.index.query.QueryBuilders
25+
import org.opensearch.search.aggregations.bucket.terms.Terms
26+
import org.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder
27+
import org.opensearch.search.builder.SearchSourceBuilder
28+
import org.opensearch.threadpool.Scheduler
29+
import org.opensearch.threadpool.ThreadPool
30+
import org.opensearch.transport.client.Client
31+
32+
/**
33+
* An anti-entropy background job that periodically scans for alerts whose monitors no longer
34+
* exist in .opendistro-alerting-config ("orphaned alerts") and moves them to the alert history
35+
* index with state = DELETED.
36+
*
37+
* Normally, when a monitor is deleted, the JobSweeper's IndexingOperationListener.postDelete
38+
* callback fires, which deschedules the monitor and moves its alerts to history via AlertMover.
39+
* However, postDelete does not always fire reliably — particularly when a monitor deletion
40+
* occurs while one of its executions is in flight. In this case,
41+
* alerts remain stranded in the active alerts index indefinitely.
42+
*
43+
* This sweeper is not a bug fix for postDelete — it is a safety net. It runs independently on the
44+
* cluster manager node and cleans up any orphaned alerts that postDelete missed, ensuring the
45+
* system eventually converges to a consistent state.
46+
*
47+
* Runs only on the cluster manager node to avoid duplicate work.
48+
*/
49+
class OrphanedAlertSweeper(
50+
settings: Settings,
51+
private val client: Client,
52+
private val threadPool: ThreadPool,
53+
private val clusterService: ClusterService
54+
) : ClusterStateListener {
55+
56+
private val logger = LogManager.getLogger(OrphanedAlertSweeper::class.java)
57+
private val scope = CoroutineScope(Dispatchers.IO)
58+
59+
@Volatile private var sweepPeriod = SWEEP_PERIOD.get(settings)
60+
@Volatile private var enabled = ENABLED.get(settings)
61+
private var scheduledSweep: Scheduler.Cancellable? = null
62+
private var isClusterManager = false
63+
64+
init {
65+
clusterService.addListener(this)
66+
clusterService.clusterSettings.addSettingsUpdateConsumer(SWEEP_PERIOD) {
67+
sweepPeriod = it
68+
if (clusterService.state().nodes.isLocalNodeElectedClusterManager && enabled) reschedule()
69+
}
70+
clusterService.clusterSettings.addSettingsUpdateConsumer(ENABLED) {
71+
enabled = it
72+
if (clusterService.state().nodes.isLocalNodeElectedClusterManager) {
73+
if (enabled) reschedule() else cancel()
74+
}
75+
}
76+
}
77+
78+
override fun clusterChanged(event: ClusterChangedEvent) {
79+
if (this.isClusterManager != event.localNodeClusterManager()) {
80+
this.isClusterManager = event.localNodeClusterManager()
81+
if (this.isClusterManager && enabled) {
82+
reschedule()
83+
} else {
84+
cancel()
85+
}
86+
}
87+
}
88+
89+
private fun reschedule() {
90+
cancel()
91+
scheduledSweep = threadPool.scheduleWithFixedDelay(
92+
{ scope.launch { withTimeout(SWEEP_TIMEOUT_MS) { sweepOrphanedAlerts() } } },
93+
sweepPeriod,
94+
ThreadPool.Names.MANAGEMENT
95+
)
96+
logger.info("Orphaned alert sweeper scheduled with period: $sweepPeriod")
97+
}
98+
99+
private fun cancel() {
100+
scheduledSweep?.cancel()
101+
scheduledSweep = null
102+
}
103+
104+
internal suspend fun sweepOrphanedAlerts() {
105+
try {
106+
if (!clusterService.state().routingTable().hasIndex(ALERT_INDEX)) {
107+
logger.debug("Alert index does not exist, skipping sweep")
108+
return
109+
}
110+
111+
val alertMonitorIds = getAlertMonitorIds()
112+
if (alertMonitorIds.isEmpty()) {
113+
logger.debug("No alerts found in active index")
114+
return
115+
}
116+
117+
// Check which monitors still exist in the config index
118+
val existingIds = if (clusterService.state().routingTable().hasIndex(ScheduledJob.SCHEDULED_JOBS_INDEX)) {
119+
getExistingMonitorIds(alertMonitorIds)
120+
} else {
121+
emptySet()
122+
}
123+
124+
val orphanedMonitorIds = alertMonitorIds - existingIds
125+
if (orphanedMonitorIds.isEmpty()) {
126+
logger.debug("No orphaned alerts found")
127+
return
128+
}
129+
130+
logger.info("Found ${orphanedMonitorIds.size} orphaned monitor(s) with active alerts: $orphanedMonitorIds")
131+
132+
// Move orphaned alerts to history
133+
for (monitorId in orphanedMonitorIds) {
134+
try {
135+
AlertMover.moveAlerts(client, monitorId, null)
136+
logger.info("Cleaned up orphaned alerts for monitor [$monitorId]")
137+
} catch (e: Exception) {
138+
logger.error("Failed to clean up orphaned alerts for monitor [$monitorId]", e)
139+
}
140+
}
141+
} catch (e: Exception) {
142+
logger.error("Error during orphaned alert sweep", e)
143+
}
144+
}
145+
146+
// retrieves the monitor IDs referenced in the .opendistro-alerting-alerts index's alerts
147+
// the possibility here is that some of these monitor IDs might no longer exist
148+
private suspend fun getAlertMonitorIds(): Set<String> {
149+
val agg = TermsAggregationBuilder("monitor_ids")
150+
.field(Alert.MONITOR_ID_FIELD)
151+
.size(1000)
152+
val searchSource = SearchSourceBuilder()
153+
.size(0)
154+
.aggregation(agg)
155+
val searchRequest = SearchRequest(ALERT_INDEX).source(searchSource)
156+
157+
val response: SearchResponse = client.suspendUntil { search(searchRequest, it) }
158+
val termsAgg = response.aggregations?.get<Terms>("monitor_ids") ?: return emptySet()
159+
return termsAgg.buckets.map { it.keyAsString }.toSet()
160+
}
161+
162+
// retrieves the monitor IDs that currently exist in .opendistro-alerting-config
163+
// this is the source of truth for what monitors do and do not exist
164+
private suspend fun getExistingMonitorIds(monitorIds: Set<String>): Set<String> {
165+
val searchSource = SearchSourceBuilder()
166+
.size(monitorIds.size)
167+
.query(QueryBuilders.idsQuery().addIds(*monitorIds.toTypedArray()))
168+
.fetchSource(false)
169+
val searchRequest = SearchRequest(ScheduledJob.SCHEDULED_JOBS_INDEX).source(searchSource)
170+
171+
val response: SearchResponse = client.suspendUntil { search(searchRequest, it) }
172+
return response.hits.hits.map { it.id }.toSet()
173+
}
174+
175+
companion object {
176+
val SWEEP_PERIOD = AlertingSettings.ORPHANED_ALERT_SWEEP_PERIOD
177+
val ENABLED = AlertingSettings.ORPHANED_ALERT_SWEEP_ENABLED
178+
const val SWEEP_TIMEOUT_MS = 60_000L
179+
}
180+
}

alerting/src/main/kotlin/org/opensearch/alerting/settings/AlertingSettings.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,18 @@ class AlertingSettings {
210210
Setting.Property.NodeScope, Setting.Property.Dynamic
211211
)
212212

213+
val ORPHANED_ALERT_SWEEP_PERIOD = Setting.positiveTimeSetting(
214+
"plugins.alerting.orphaned_alert_sweep_period",
215+
TimeValue(5, TimeUnit.MINUTES),
216+
Setting.Property.NodeScope, Setting.Property.Dynamic
217+
)
218+
219+
val ORPHANED_ALERT_SWEEP_ENABLED = Setting.boolSetting(
220+
"plugins.alerting.orphaned_alert_sweep_enabled",
221+
true,
222+
Setting.Property.NodeScope, Setting.Property.Dynamic
223+
)
224+
213225
val REQUEST_TIMEOUT = Setting.positiveTimeSetting(
214226
"plugins.alerting.request_timeout",
215227
LegacyOpenDistroAlertingSettings.REQUEST_TIMEOUT,

0 commit comments

Comments
 (0)