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 @@ -16,6 +16,7 @@ import org.opensearch.alerting.action.SearchEmailAccountAction
import org.opensearch.alerting.action.SearchEmailGroupAction
import org.opensearch.alerting.alerts.AlertIndices
import org.opensearch.alerting.alerts.AlertIndices.Companion.ALL_ALERT_INDEX_PATTERN
import org.opensearch.alerting.alerts.OrphanedAlertSweeper
import org.opensearch.alerting.comments.CommentsIndices
import org.opensearch.alerting.comments.CommentsIndices.Companion.ALL_COMMENTS_INDEX_PATTERN
import org.opensearch.alerting.core.JobSweeper
Expand Down Expand Up @@ -196,6 +197,7 @@ internal class AlertingPlugin : PainlessExtension, ActionPlugin, ScriptPlugin, R
lateinit var runner: MonitorRunnerService
lateinit var scheduler: JobScheduler
lateinit var sweeper: JobSweeper
lateinit var orphanedAlertSweeper: OrphanedAlertSweeper
lateinit var scheduledJobIndices: ScheduledJobIndices
lateinit var commentsIndices: CommentsIndices
lateinit var docLevelMonitorQueries: DocLevelMonitorQueries
Expand Down Expand Up @@ -359,6 +361,7 @@ internal class AlertingPlugin : PainlessExtension, ActionPlugin, ScriptPlugin, R
docLevelMonitorQueries = DocLevelMonitorQueries(client, clusterService)
scheduler = JobScheduler(threadPool, runner)
sweeper = JobSweeper(environment.settings(), client, clusterService, threadPool, xContentRegistry, scheduler, ALERTING_JOB_TYPES)
orphanedAlertSweeper = OrphanedAlertSweeper(environment.settings(), client, threadPool, clusterService)
destinationMigrationCoordinator = DestinationMigrationCoordinator(client, clusterService, threadPool, scheduledJobIndices)
this.threadPool = threadPool
this.clusterService = clusterService
Expand Down Expand Up @@ -421,7 +424,8 @@ internal class AlertingPlugin : PainlessExtension, ActionPlugin, ScriptPlugin, R
alertService,
triggerService,
sdkClient,
monitorJobPoller
monitorJobPoller,
orphanedAlertSweeper
)
}

Expand Down Expand Up @@ -527,7 +531,9 @@ internal class AlertingPlugin : PainlessExtension, ActionPlugin, ScriptPlugin, R
AlertingSettings.JOB_QUEUE_ACCOUNT_PROVIDER_TYPE,
AlertingSettings.TARGET_TYPE_TO_SERVICE_NAME,
AlertingSettings.TENANT_ACCOUNT_ID_HEADER,
AlertingSettings.TENANT_RESOURCE_ID_HEADER
AlertingSettings.TENANT_RESOURCE_ID_HEADER,
AlertingSettings.ORPHANED_ALERT_SWEEP_PERIOD,
AlertingSettings.ORPHANED_ALERT_SWEEP_ENABLED
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.alerting.alerts

import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import org.apache.logging.log4j.LogManager
import org.opensearch.action.search.SearchRequest
import org.opensearch.action.search.SearchResponse
import org.opensearch.alerting.alerts.AlertIndices.Companion.ALERT_INDEX
import org.opensearch.alerting.opensearchapi.suspendUntil
import org.opensearch.alerting.settings.AlertingSettings
import org.opensearch.cluster.ClusterChangedEvent
import org.opensearch.cluster.ClusterStateListener
import org.opensearch.cluster.service.ClusterService
import org.opensearch.common.lifecycle.LifecycleListener
import org.opensearch.common.settings.Settings
import org.opensearch.commons.alerting.model.Alert
import org.opensearch.commons.alerting.model.ScheduledJob
import org.opensearch.index.query.QueryBuilders
import org.opensearch.search.aggregations.bucket.terms.Terms
import org.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder
import org.opensearch.search.builder.SearchSourceBuilder
import org.opensearch.threadpool.Scheduler
import org.opensearch.threadpool.ThreadPool
import org.opensearch.transport.client.Client

/**
* An anti-entropy background job that periodically scans for alerts whose monitors no longer
* exist in .opendistro-alerting-config ("orphaned alerts") and moves them to the alert history
* index with state = DELETED.
*
* Normally, when a monitor is deleted, the JobSweeper's IndexingOperationListener.postDelete
* callback fires, which deschedules the monitor and moves its alerts to history via AlertMover.
* However, postDelete does not always fire reliably — particularly when a monitor deletion
* occurs while one of its executions is in flight. In this case,
* alerts remain stranded in the active alerts index indefinitely.
*
* This sweeper is not a bug fix for postDelete — it is a safety net. It runs independently on the
* cluster manager node and cleans up any orphaned alerts that postDelete missed, ensuring the
* system eventually converges to a consistent state.
*
* Runs only on the cluster manager node to avoid duplicate work.
*/
class OrphanedAlertSweeper(
settings: Settings,
private val client: Client,
private val threadPool: ThreadPool,
private val clusterService: ClusterService

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coroutine scope can be permanently broken by an unhandled failure

This uses the default Job(). If any uncaught exception escapes withTimeout(...) in reschedule() (e.g. a TimeoutCancellationException thrown after the inner try/catch in sweepOrphanedAlerts returns, or any throwable raised before the try block is entered), the scope's job is cancelled and all future scheduled sweeps silently no-opscope.launch { ... } on a cancelled scope is a no-op, and the user gets no signal that the sweeper has stopped working.

Suggestions:

  • Use SupervisorJob(): CoroutineScope(SupervisorJob() + Dispatchers.IO) so a single failure doesn't tear down the scope.
  • Or wrap the withTimeout call in a try/catch at the launch site so timeout/cancellation never propagates out of the coroutine.
  • Add a CoroutineExceptionHandler for visibility either way.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to use SupervisorJob + CoroutineExceptionHandler

) : ClusterStateListener, LifecycleListener() {

private val logger = LogManager.getLogger(OrphanedAlertSweeper::class.java)
private val exceptionHandler = CoroutineExceptionHandler { _, exception ->
logger.error("Uncaught exception in OrphanedAlertSweeper coroutine", exception)
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)

@Volatile private var sweepPeriod = SWEEP_PERIOD.get(settings)
@Volatile private var enabled = ENABLED.get(settings)
private var scheduledSweep: Scheduler.Cancellable? = null
private var isClusterManager = false

init {
clusterService.addListener(this)
clusterService.addLifecycleListener(this)
clusterService.clusterSettings.addSettingsUpdateConsumer(SWEEP_PERIOD) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Boot-time race: init doesn't schedule a sweep

Scheduling is driven solely by clusterChanged. If the local node is already the elected cluster manager at the moment this listener is registered — uncommon but possible during a fast restart of a single-CM cluster, or when the plugin is installed on an already-running CM — clusterChanged may not fire a transition for that election and isClusterManager stays false forever, so the sweeper never starts.

JobSweeper avoids this via LifecycleListener.afterStart() (see JobSweeper.kt:124). I'd mirror that pattern here:

  • Implement LifecycleListener, register with clusterService.addLifecycleListener(this), and bootstrap from afterStart() by checking clusterService.state().nodes.isLocalNodeElectedClusterManager.
  • Also pair it with beforeStop/beforeClose to cancel the schedule, remove the cluster listener, and cancel scope — currently nothing tears these down on node shutdown.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to make the sweeper also implement LifecycleListener

sweepPeriod = it
if (clusterService.state().nodes.isLocalNodeElectedClusterManager && enabled) reschedule()
}
clusterService.clusterSettings.addSettingsUpdateConsumer(ENABLED) {
enabled = it
if (clusterService.state().nodes.isLocalNodeElectedClusterManager) {
if (enabled) reschedule() else cancel()
}
}
}

override fun afterStart() {
if (clusterService.state().nodes.isLocalNodeElectedClusterManager && enabled) {
isClusterManager = true
reschedule()
}
}

override fun beforeStop() {
cancel()
clusterService.removeListener(this)
}

override fun beforeClose() {
scope.cancel()
}

override fun clusterChanged(event: ClusterChangedEvent) {
if (this.isClusterManager != event.localNodeClusterManager()) {
this.isClusterManager = event.localNodeClusterManager()
if (this.isClusterManager && enabled) {
reschedule()
} else {
cancel()
}
}
}

private fun reschedule() {
cancel()
scheduledSweep = threadPool.scheduleWithFixedDelay(
{ scope.launch { withTimeout(SWEEP_TIMEOUT_MS) { sweepOrphanedAlerts() } } },
sweepPeriod,
ThreadPool.Names.MANAGEMENT
)
logger.info("Orphaned alert sweeper scheduled with period: $sweepPeriod")
}

private fun cancel() {
scheduledSweep?.cancel()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrent sweeps possible if cluster-manager-ship oscillates

cancel() only cancels the Scheduler.Cancellable — it doesn't track or cancel the in-flight coroutine Job returned by scope.launch { ... } at line 117. If CM-ship flips off and back on (rare but possible during partition flapping or network blips), the previous sweep's coroutine continues running while a new schedule is armed. Two sweeps can call AlertMover.moveAlerts for the same monitor IDs in parallel.

AlertMover uses versionType=EXTERNAL_GTE so the worst case is duplicated history-index writes / version-conflict log noise — no data corruption — but it's worth tightening:

private var inFlightSweep: Job? = null

private fun reschedule() {
    cancel()
    scheduledSweep = threadPool.scheduleWithFixedDelay(
        { inFlightSweep = scope.launch { withTimeout(SWEEP_TIMEOUT_MS) { sweepOrphanedAlerts() } } },
        sweepPeriod,
        ThreadPool.Names.MANAGEMENT
    )
}

private fun cancel() {
    scheduledSweep?.cancel()
    scheduledSweep = null
    inFlightSweep?.cancel()
    inFlightSweep = null
}

scheduledSweep = null
}

internal suspend fun sweepOrphanedAlerts() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know how alerts are getting orphaned? We shouldn't have orphans if the delete logic is working correctly so it seems like a bandaid to add a sweeper rather than closing the gaps in the delete logic (if any)

I would advocate that we should fix any bugs that lead to orphaned alerts rather than relying on an out-of-band cleanup process

try {
if (!clusterService.state().routingTable().hasIndex(ALERT_INDEX)) {
logger.debug("Alert index does not exist, skipping sweep")
return
}

val alertMonitorIds = getAlertMonitorIds()
if (alertMonitorIds.isEmpty()) {
logger.debug("No alerts found in active index")
return
}

// Check which monitors still exist in the config index
val existingIds = if (clusterService.state().routingTable().hasIndex(ScheduledJob.SCHEDULED_JOBS_INDEX)) {
getExistingMonitorIds(alertMonitorIds)
} else {
emptySet()
}

val orphanedMonitorIds = alertMonitorIds - existingIds
if (orphanedMonitorIds.isEmpty()) {
logger.debug("No orphaned alerts found")
return
}

logger.info("Found ${orphanedMonitorIds.size} orphaned monitor(s) with active alerts: $orphanedMonitorIds")

// Move orphaned alerts to history
for (monitorId in orphanedMonitorIds) {
try {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 BLOCKING — sweeper can incorrectly delete active workflow (chained) alerts

Per AlertService.kt:949, chained alerts can carry monitor_id="" and use workflow_id instead. The terms aggregation in getAlertMonitorIds() will return "" as a valid bucket key. That empty string is then:

  1. Looked up via idsQuery().addIds("") against SCHEDULED_JOBS_INDEX — returns no hits.
  2. Therefore added to orphanedMonitorIds.
  3. Passed to AlertMover.moveAlerts(client, "", null), which runs termQuery(MONITOR_ID_FIELD, "") (AlertMover.kt:62) — matching every chained alert in the cluster whose monitor_id field is empty.

The result: chained alerts for active, non-deleted workflows get moved to history with state=DELETED on the very first sweep cycle. This is a regression risk, not just a coverage gap.

Required fix (one of):

  • Filter the terms agg to non-empty monitor IDs (e.g. add a must(existsQuery) + mustNot(termQuery("monitor_id", "")) pre-filter), AND
  • Add a parallel pass that aggregates Alert.WORKFLOW_ID_FIELD, reconciles against workflow IDs in SCHEDULED_JOBS_INDEX, and invokes the workflow variant of AlertMover.moveAlerts for genuine workflow orphans.

At minimum, please verify with a test that creates a workflow with chained alerts, leaves the workflow alive, runs the sweeper, and asserts the chained alerts are NOT moved to history.

AlertMover.moveAlerts(client, monitorId, null)
logger.info("Cleaned up orphaned alerts for monitor [$monitorId]")
} catch (e: Exception) {
logger.error("Failed to clean up orphaned alerts for monitor [$monitorId]", e)
}
}
} catch (e: Exception) {
logger.error("Error during orphaned alert sweep", e)
}
}

// retrieves the monitor IDs referenced in the .opendistro-alerting-alerts index's alerts
// the possibility here is that some of these monitor IDs might no longer exist

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

60s timeout may be too tight for the first sweep after an outage

AlertMover.moveAlerts is called sequentially in a for loop, all under one withTimeout(SWEEP_TIMEOUT_MS). Each call is roughly 1 search + 1 bulk index + 1 bulk delete (3 round-trips). On the first sweep after an extended outage where many orphans accumulated, ~100 orphans could blow the 60s budget and lose progress mid-loop — and the next cycle starts from scratch with the same overload.

Options:

  • Cap orphans-per-sweep (e.g. process up to N per cycle, drain across cycles).
  • Make timeout proportional to orphan count.
  • Process moves with bounded parallelism (e.g. coroutineScope { orphans.chunked(K).forEach { ... } }).
  • Or expose SWEEP_TIMEOUT_MS as a cluster setting alongside the period/enabled flags so operators can tune it without a code change.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proposed mitigation to cap orphans per sweep is already in place, just not very explicitly. moveAlerts() runs a search on the active alerts index without explicitly passing a size param (reference). This means it defaults to returning only 10 orphaned Alerts even within a single zombie Monitor. In this respect, this sweeper already cleans at most 10 alerts per orphaned monitor per sweep.

Even if within a 10 orphaned Alerts cleanup batch, the timeout can still be hit in the worst case but that should not be an issue:

  • case: timeout hits after both the copy and the delete bulk requests are sent. In this case, the timeout simply prevents the thread from reading in the result, but the task is done. On the next sweep, the alerts will have already been cleaned up, and the sweeper moves on without issue
  • case: timeout happens between the copy and the delete bulk requests. In this case, there are now 2 copies of the orphaned alerts in the active and history indices. On the next sweep, these are found again, but with EXTERNAL_GTE version checks, idempotency is enforced and no duplication happens, progress is still made. The only way this case scenario can stall progress is if on every single sweep, the timeout hits between the copy and delete bulk requests, which are back to back lines.
  • case: timeout happens before copy or delete requests, stalls on the search for orphaned Active alerts. If the search to the active alerts concrete index takes more than 60s, the cluster has bigger problems than the alert sweeper not catching all Alerts. Once the cluster can breathe again, the above 2 cases kick in and make progress.

private suspend fun getAlertMonitorIds(): Set<String> {
val agg = TermsAggregationBuilder("monitor_ids")
.field(Alert.MONITOR_ID_FIELD)
.size(1000)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason size is 1000 here?

This is an edge case, but the plugin does support the ALERTING_MAX_MONITORS cluster setting to potentially have more than 1000 monitors.
https://github.com/opensearch-project/alerting/blob/main/alerting/src/main/kotlin/org/opensearch/alerting/settings/AlertingSettings.kt#L35

val searchSource = SearchSourceBuilder()
.size(0)
.aggregation(agg)
val searchRequest = SearchRequest(ALERT_INDEX).source(searchSource)

val response: SearchResponse = client.suspendUntil { search(searchRequest, it) }
val termsAgg = response.aggregations?.get<Terms>("monitor_ids") ?: return emptySet()
return termsAgg.buckets.map { it.keyAsString }.toSet()
}

// retrieves the monitor IDs that currently exist in .opendistro-alerting-config
// this is the source of truth for what monitors do and do not exist
private suspend fun getExistingMonitorIds(monitorIds: Set<String>): Set<String> {
val searchSource = SearchSourceBuilder()
.size(monitorIds.size)
.query(QueryBuilders.idsQuery().addIds(*monitorIds.toTypedArray()))
.fetchSource(false)
val searchRequest = SearchRequest(ScheduledJob.SCHEDULED_JOBS_INDEX).source(searchSource)

val response: SearchResponse = client.suspendUntil { search(searchRequest, it) }
return response.hits.hits.map { it.id }.toSet()
}

companion object {
val SWEEP_PERIOD = AlertingSettings.ORPHANED_ALERT_SWEEP_PERIOD
val ENABLED = AlertingSettings.ORPHANED_ALERT_SWEEP_ENABLED
const val SWEEP_TIMEOUT_MS = 60_000L

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,18 @@ class AlertingSettings {
Setting.Property.NodeScope, Setting.Property.Dynamic
)

val ORPHANED_ALERT_SWEEP_PERIOD = Setting.positiveTimeSetting(
"plugins.alerting.orphaned_alert_sweep_period",
TimeValue(5, TimeUnit.MINUTES),
Setting.Property.NodeScope, Setting.Property.Dynamic
)

val ORPHANED_ALERT_SWEEP_ENABLED = Setting.boolSetting(
"plugins.alerting.orphaned_alert_sweep_enabled",
true,
Setting.Property.NodeScope, Setting.Property.Dynamic
)

val REQUEST_TIMEOUT = Setting.positiveTimeSetting(
"plugins.alerting.request_timeout",
LegacyOpenDistroAlertingSettings.REQUEST_TIMEOUT,
Expand Down
Loading
Loading