Skip to content

Fix ClassCastException from immutable map returned by StreamInput.readMap()#968

Open
thecodingshrimp wants to merge 7 commits into
opensearch-project:mainfrom
thecodingshrimp:fix/monitor-uimetadata-immutable-map-cast
Open

Fix ClassCastException from immutable map returned by StreamInput.readMap()#968
thecodingshrimp wants to merge 7 commits into
opensearch-project:mainfrom
thecodingshrimp:fix/monitor-uimetadata-immutable-map-cast

Conversation

@thecodingshrimp

@thecodingshrimp thecodingshrimp commented Jun 9, 2026

Copy link
Copy Markdown

Summary

Fixes #967

StreamInput.readMap() documents that for zero-size maps it might return an immutable map (Collections.emptyMap()). Three deserialization constructors in this repo perform an unchecked cast (suppressWarning) that assumes a MutableMap, causing a ClassCastException at runtime when a monitor with empty uiMetadata, lastRunContext, or queryResults is deserialized over transport.

Exception:

kotlin.collections.EmptyMap cannot be cast to kotlin.collections.MutableMap

Root cause

StreamOutput.writeMap / StreamInput.readMap guarantee only that the result implements java.util.Map. The Javadoc states:

If the returned map contains any entries it will be mutable. If it is empty it might be immutable.

The suppressWarning() helper performs map as MutableMap<String, Any> without a defensive copy, which fails when the deserialized map is Collections.emptyMap().

Identified via opensearch-project/security-analytics#1722TransportIndexDetectorAction passes Map.of() as uiMetadata when constructing a Monitor, which serializes as a zero-size map, readMap() returns Collections.emptyMap(), and the cast fails.

Changes

Replace suppressWarning(sin.readMap()) with sin.readMap()?.toMutableMap() ?: mutableMapOf() at all three callsites and remove the suppressWarning() helper function entirely.

File Change
Monitor.kt uiMetadata deserialization — safe copy
MonitorMetadata.kt lastRunContext deserialization — safe copy
Alert.kt queryResults deserialization — safe copy; remove suppressWarning import

Testing

All existing Monitor-related tests pass (./gradlew test --tests "*Monitor*" → BUILD SUCCESSFUL).

Check List

  • Existing tests pass
  • Commits signed off (DCO)
  • New tests (not added — fix is a defensive copy at deserialization; behaviour is identical for non-empty maps)

Related

…dMap()

StreamInput.readMap() documents that empty maps 'might be immutable'.
Replace all unsafe suppressWarning() unchecked casts and raw readMap() casts
with safe toMutableMap() copies in deserialization constructors across all
affected alerting model classes.

Fixed files:
- Monitor.kt: uiMetadata deserialization
- Alert.kt: queryResults deserialization
- MonitorMetadata.kt: lastRunContext and sourceToQueryIndexMapping
- IndexExecutionContext.kt: lastRunContext, updatedLastRunContext
- DocLevelMonitorFanOutResponse.kt: lastRunContexts; remove suppressWarning helper
- WorkflowRunResult.kt: triggerResults; remove suppressWarning helper

This prevents the kotlin.collections.EmptyMap cannot be cast to
kotlin.collections.MutableMap exception when transport-deserializing
monitors with empty map fields.

Fixes opensearch-project/security-analytics#1715

Signed-off-by: thecodingshrimp <leonard.stutzer@sap.com>
@thecodingshrimp
thecodingshrimp force-pushed the fix/monitor-uimetadata-immutable-map-cast branch from f3545a8 to 9511928 Compare June 9, 2026 11:26
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 02a9faf)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Wire Format Change

The order of deserialization was changed: lastRunContexts is now read before inputResults, and triggerResults is read before exception. The new order is lastRunContexts, inputResults, triggerResults, exception, while the old order was lastRunContexts, inputResults, triggerResults, exception in reading but this depends on writeTo order. If writeTo was not updated to match (it isn't shown in the diff), this will break wire compatibility with older nodes and cause deserialization failures in mixed-cluster or rolling-upgrade scenarios. Please verify the writeTo method writes fields in the exact same order as they are read.

constructor(sin: StreamInput) : this(
    nodeId = sin.readString(),
    executionId = sin.readString(),
    monitorId = sin.readString(),
    lastRunContexts = sin.readMap()?.toMutableMap() ?: mutableMapOf(),
    inputResults = InputRunResults.readFrom(sin),
    triggerResults = readTriggerResults(sin),
    exception = sin.readException()
)
Type-Erased Cast

sin.readMapAsMutableMap() as Map<String, TriggerResult> performs an unchecked cast to a generic type. Since readMap() returns Map<String, Object> and the values are actually raw maps (not TriggerResult instances), any downstream code that iterates values and treats them as TriggerResult will hit a ClassCastException. The previous code had the same latent issue, but this should be documented or fixed by reading values with the proper typed reader.

sin.readMapAsMutableMap() as Map<String, TriggerResult> // triggerResults
Incorrect Deserialization

triggerResults is deserialized via sin.readMap() and cast to Map<String, ChainedAlertTriggerRunResult>, but readMap() returns generic values (not ChainedAlertTriggerRunResult instances). The test acknowledges this limitation ('WorkflowRunResult.writeTo() uses the untyped writeMap which cannot serialize complex Writeable values'). This means any non-empty triggerResults transported over the wire is effectively broken — consumers will get a map containing raw deserialized values, not ChainedAlertTriggerRunResult. This is a pre-existing bug not introduced by this PR, but the fix leaves it in place.

triggerResults = (sin.readMap()?.toMutableMap() ?: mutableMapOf()) as Map<String, ChainedAlertTriggerRunResult>

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 02a9faf

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Use typed readMap for Writeable values

Using the untyped sin.readMap() for a map whose values are
ChainedAlertTriggerRunResult will not correctly deserialize the value objects —
readMap() reads generic values, not Writeable objects. This means the cast will fail
at runtime the moment the map contains any entries. Use the typed overload
sin.readMap(StreamInput::readString, ::ChainedAlertTriggerRunResult) to properly
deserialize entries.

src/main/kotlin/org/opensearch/commons/alerting/model/WorkflowRunResult.kt [38]

-triggerResults = (sin.readMap()?.toMutableMap() ?: mutableMapOf()) as Map<String, ChainedAlertTriggerRunResult>
+triggerResults = sin.readMap(StreamInput::readString, ::ChainedAlertTriggerRunResult)
+    ?.toMutableMap() ?: mutableMapOf()
Suggestion importance[1-10]: 7

__

Why: Valid concern: the untyped readMap() cannot properly reconstruct ChainedAlertTriggerRunResult Writeable instances, and the cast will fail for non-empty maps. However, this issue predates this PR and the writer side uses the same untyped approach.

Medium
Deserialize trigger results with typed reader

readMapAsMutableMap() uses the untyped readMap(), which does not know how to
reconstruct TriggerResult subclass instances from the stream. Any non-empty map will
produce values of the wrong type and the unchecked cast will defer the failure to
first use. Consider deserializing trigger results using a typed reader or
restructuring serialization to use writeMap/readMap with the proper Writeable
readers.

src/main/kotlin/org/opensearch/commons/alerting/model/MonitorRunResult.kt [40]

-sin.readMapAsMutableMap() as Map<String, TriggerResult> // triggerResults
+sin.readMap(StreamInput::readString) { TriggerRunResult.readFrom(it) as TriggerResult }
+    ?.toMutableMap() ?: mutableMapOf()
Suggestion importance[1-10]: 7

__

Why: Correct observation that untyped readMap() cannot reconstruct TriggerResult subclasses, potentially causing runtime failures with non-empty maps. This existed prior to the PR but the suggestion highlights a real correctness gap.

Medium
General
Avoid unchecked cast for typed string map

Casting MutableMap<String, Any> to MutableMap<String, String> via an unchecked cast
is unsafe and can hide runtime issues if non-String values are ever present.
Consider explicitly filtering/converting values to String, or reading with a typed
reader such as sin.readMap(StreamInput::readString, StreamInput::readString) to
ensure type safety.

src/main/kotlin/org/opensearch/commons/alerting/model/MonitorMetadata.kt [39-40]

 lastRunContext = sin.readMap()?.toMutableMap() ?: mutableMapOf(),
-sourceToQueryIndexMapping = (sin.readMap()?.toMutableMap() ?: mutableMapOf()) as MutableMap<String, String>
+sourceToQueryIndexMapping = sin.readMap(StreamInput::readString, StreamInput::readString)
+    ?.toMutableMap() ?: mutableMapOf()
Suggestion importance[1-10]: 6

__

Why: Using a typed readMap(readString, readString) is safer than the unchecked cast to MutableMap<String, String>, avoiding potential ClassCastException issues, though the original writer path likely writes strings.

Low
Safely copy trigger results into mutable map

The HashMap(raw as Map<...>) copy is only executed for non-empty maps; when raw is
non-empty but is an immutable map returned by readMap, the cast succeeds but callers
that mutate the result will still work because of the copy. However, if readMap
returns a mutable non-HashMap, the extra copy is unnecessary. More importantly, the
cast may fail at runtime if the erased generic type check is enforced; using
mapValues { it.value as DocumentLevelTriggerRunResult } into a new HashMap is safer
and avoids relying on erasure.

src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt [58-64]

-private fun readTriggerResults(sin: StreamInput): Map<String, DocumentLevelTriggerRunResult> {
+private fun readTriggerResults(sin: StreamInput): MutableMap<String, DocumentLevelTriggerRunResult> {
     val raw = sin.readMap(StreamInput::readString, DocumentLevelTriggerRunResult::readFrom)
-    if (raw.isEmpty()) return mutableMapOf()
-    // readFrom returns TriggerRunResult (base type) but always constructs DocumentLevelTriggerRunResult
-    @Suppress("UNCHECKED_CAST")
-    return HashMap(raw as Map<String, DocumentLevelTriggerRunResult>)
+    val result = HashMap<String, DocumentLevelTriggerRunResult>(raw.size)
+    raw.forEach { (k, v) -> result[k] = v as DocumentLevelTriggerRunResult }
+    return result
 }
Suggestion importance[1-10]: 3

__

Why: The existing code already handles the empty-map case and copies to a HashMap. The suggested change is a minor stylistic improvement with negligible practical impact since readFrom already constructs DocumentLevelTriggerRunResult.

Low

Previous suggestions

Suggestions up to commit e518795
CategorySuggestion                                                                                                                                    Impact
Possible issue
Unsafe cast to typed trigger results

The cast to Map<String, ChainedAlertTriggerRunResult> is unsafe and will fail at
runtime since readMap() returns a map with generic values, not typed
ChainedAlertTriggerRunResult instances. Use a typed read method or validate entries
before casting.

src/main/kotlin/org/opensearch/commons/alerting/model/WorkflowRunResult.kt [38]

-triggerResults = (sin.readMap()?.toMutableMap() ?: mutableMapOf()) as Map<String, ChainedAlertTriggerRunResult>
+triggerResults = sin.readMap(StreamInput::readString, ChainedAlertTriggerRunResult::readFrom)?.toMutableMap() ?: mutableMapOf()
Suggestion importance[1-10]: 9

__

Why: The cast to Map<String, ChainedAlertTriggerRunResult> is unsafe because readMap() returns untyped values. This will cause ClassCastException when accessing the map entries, making it a critical bug that needs fixing.

High
Unsafe cast to nested typed map

The cast to nested MutableMap<String, MutableMap<String, ActionRunResult>> is
unsafe. The readMapAsMutableMap() returns MutableMap<String, Any>, and casting to a
nested typed structure will fail at runtime when accessing nested values.

src/main/kotlin/org/opensearch/commons/alerting/model/BucketLevelTriggerRunResult.kt [28]

-sin.readMapAsMutableMap() as MutableMap<String, MutableMap<String, ActionRunResult>>
+sin.readMap(StreamInput::readString) { s -> 
+    s.readMap(StreamInput::readString, ActionRunResult::readFrom) 
+}?.toMutableMap() ?: mutableMapOf()
Suggestion importance[1-10]: 9

__

Why: The cast to nested MutableMap<String, MutableMap<String, ActionRunResult>> is unsafe. The readMapAsMutableMap() returns MutableMap<String, Any>, and this cast will fail when accessing nested values, causing runtime errors.

High
Unsafe cast to typed trigger result

The cast to Map<String, TriggerResult> is unsafe since readMapAsMutableMap() returns
MutableMap<String, Any>. The values are not typed as TriggerResult instances and
will cause ClassCastException when accessed. Use a typed read method instead.

src/main/kotlin/org/opensearch/commons/alerting/model/MonitorRunResult.kt [40]

-sin.readMapAsMutableMap() as Map<String, TriggerResult>
+sin.readMap(StreamInput::readString) { s -> 
+    TriggerRunResult.readFrom(s) as TriggerResult 
+} ?: mutableMapOf()
Suggestion importance[1-10]: 9

__

Why: The cast to Map<String, TriggerResult> is unsafe since readMapAsMutableMap() returns MutableMap<String, Any>. This will cause ClassCastException when accessing map values, representing a critical type safety issue.

High
Unsafe cast to MutableMap

The cast to MutableMap<String, String> is unsafe and can fail at runtime if the
deserialized map contains non-String values. Consider validating the map entries
before casting or using a safer conversion method that filters entries by type.

src/main/kotlin/org/opensearch/commons/alerting/model/MonitorMetadata.kt [40]

-sourceToQueryIndexMapping = (sin.readMap()?.toMutableMap() ?: mutableMapOf()) as MutableMap<String, String>
+sourceToQueryIndexMapping = sin.readMap()?.mapNotNull { (k, v) -> 
+    if (k is String && v is String) k to v else null 
+}?.toMap()?.toMutableMap() ?: mutableMapOf()
Suggestion importance[1-10]: 8

__

Why: The unsafe cast to MutableMap<String, String> can fail at runtime if the deserialized map contains non-String values. This is a critical type safety issue that could cause ClassCastException in production.

Medium
Suggestions up to commit 504f10c
CategorySuggestion                                                                                                                                    Impact
Possible issue
Unsafe cast to complex type

The cast to Map<String, ChainedAlertTriggerRunResult> is unsafe and will fail at
runtime since readMap() returns a map of basic types, not complex
ChainedAlertTriggerRunResult objects. Use readMap(StreamInput::readString,
ChainedAlertTriggerRunResult::readFrom) instead.

src/main/kotlin/org/opensearch/commons/alerting/model/WorkflowRunResult.kt [38]

-triggerResults = (sin.readMap()?.toMutableMap() ?: mutableMapOf()) as Map<String, ChainedAlertTriggerRunResult>
+triggerResults = sin.readMap(StreamInput::readString, ChainedAlertTriggerRunResult::readFrom) ?: mapOf()
Suggestion importance[1-10]: 9

__

Why: The cast to Map<String, ChainedAlertTriggerRunResult> is fundamentally incorrect because readMap() without type parameters returns a map of basic types, not complex Writeable objects. This will fail at runtime. Using readMap(StreamInput::readString, ChainedAlertTriggerRunResult::readFrom) is the correct approach for deserializing complex types.

High
Unsafe cast to MutableMap

The cast to MutableMap<String, String> is unsafe and may fail at runtime if the map
contains non-String values. Consider using mapValues or explicit type checking to
ensure type safety before casting.

src/main/kotlin/org/opensearch/commons/alerting/model/MonitorMetadata.kt [40]

-sourceToQueryIndexMapping = (sin.readMap()?.toMutableMap() ?: mutableMapOf()) as MutableMap<String, String>
+sourceToQueryIndexMapping = sin.readMap()?.mapValues { it.value as String }?.toMutableMap() ?: mutableMapOf()
Suggestion importance[1-10]: 8

__

Why: The unsafe cast to MutableMap<String, String> can fail at runtime if the map contains non-String values. The suggested approach using mapValues with explicit type checking provides better type safety and prevents potential ClassCastException.

Medium
General
Remove redundant type cast

The explicit cast is redundant since readMap(StreamInput::readString,
DocumentLevelTriggerRunResult::readFrom) already returns the correct type. Remove
the cast to simplify the code.

src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt [36-37]

-triggerResults = (sin.readMap(StreamInput::readString, DocumentLevelTriggerRunResult::readFrom) ?: mapOf())
-    as Map<String, DocumentLevelTriggerRunResult>
+triggerResults = sin.readMap(StreamInput::readString, DocumentLevelTriggerRunResult::readFrom) ?: mapOf()
Suggestion importance[1-10]: 5

__

Why: The explicit cast is redundant since readMap(StreamInput::readString, DocumentLevelTriggerRunResult::readFrom) already returns Map<String, DocumentLevelTriggerRunResult>?. Removing it improves code clarity and reduces unnecessary complexity.

Low
Suggestions up to commit 9e0e029
CategorySuggestion                                                                                                                                    Impact
General
Remove redundant type cast

The cast to Map<String, DocumentLevelTriggerRunResult> is redundant since readMap
with typed readers already returns the correct type. Remove the unnecessary cast to
improve code clarity.

src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt [36-37]

-triggerResults = (sin.readMap(StreamInput::readString, DocumentLevelTriggerRunResult::readFrom) ?: mapOf())
-    as Map<String, DocumentLevelTriggerRunResult>
+triggerResults = sin.readMap(StreamInput::readString, DocumentLevelTriggerRunResult::readFrom) ?: mapOf()
Suggestion importance[1-10]: 8

__

Why: The cast is indeed redundant since readMap with typed readers (StreamInput::readString, DocumentLevelTriggerRunResult::readFrom) already returns Map<String, DocumentLevelTriggerRunResult>?. Removing it improves code clarity and eliminates the need for @Suppress("UNCHECKED_CAST").

Medium
Possible issue
Unsafe cast to MutableMap

The unsafe cast to MutableMap<String, String> can fail at runtime if the map
contains non-String values. Consider using filterIsInstance or explicit type
checking to ensure type safety before casting.

src/main/kotlin/org/opensearch/commons/alerting/model/MonitorMetadata.kt [40]

-sourceToQueryIndexMapping = (sin.readMap()?.toMutableMap() ?: mutableMapOf()) as MutableMap<String, String>
+sourceToQueryIndexMapping = sin.readMap()?.let { map ->
+    map.entries.associate { (k, v) -> k.toString() to v.toString() }.toMutableMap()
+} ?: mutableMapOf()
Suggestion importance[1-10]: 7

__

Why: The unsafe cast can cause ClassCastException at runtime if the map contains non-String values. The suggestion provides a safer approach using explicit type conversion, though it assumes toString() is appropriate for all values.

Medium
Unsafe cast to ChainedAlertTriggerRunResult map

The unsafe cast to Map<String, ChainedAlertTriggerRunResult> can cause
ClassCastException at runtime if the map values are not of the expected type. Use a
safe casting approach with type validation.

src/main/kotlin/org/opensearch/commons/alerting/model/WorkflowRunResult.kt [38]

-triggerResults = (sin.readMap()?.toMutableMap() ?: mutableMapOf()) as Map<String, ChainedAlertTriggerRunResult>
+triggerResults = sin.readMap()?.mapNotNull { (k, v) ->
+    (k as? String)?.let { key -> (v as? ChainedAlertTriggerRunResult)?.let { value -> key to value } }
+}?.toMap() ?: mapOf()
Suggestion importance[1-10]: 7

__

Why: The unsafe cast to Map<String, ChainedAlertTriggerRunResult> can cause ClassCastException at runtime. The suggested approach with mapNotNull and safe casting provides better type safety, though it silently drops invalid entries.

Medium
Suggestions up to commit 9511928
CategorySuggestion                                                                                                                                    Impact
Possible issue
Use typed readMap with deserializers

The unsafe cast to Map<String, ChainedAlertTriggerRunResult> can fail if the
deserialized map contains incompatible types. Consider using a typed readMap
overload with custom deserializers for both key and value, similar to the approach
in DocLevelMonitorFanOutResponse.

src/main/kotlin/org/opensearch/commons/alerting/model/WorkflowRunResult.kt [37]

-triggerResults = (sin.readMap()?.toMutableMap() ?: mutableMapOf()) as Map<String, ChainedAlertTriggerRunResult>
+triggerResults = sin.readMap(StreamInput::readString, ChainedAlertTriggerRunResult::readFrom) ?: mapOf()
Suggestion importance[1-10]: 7

__

Why: This is a valid improvement that suggests using typed readMap with custom deserializers instead of an unsafe cast, making it consistent with the approach in DocLevelMonitorFanOutResponse. This would eliminate the need for the unsafe cast and improve type safety.

Medium
Validate map entry types safely

The unsafe cast to MutableMap<String, String> can cause ClassCastException at
runtime if the map contains non-String keys or values. Add explicit type validation
or use filterIsInstance to ensure type safety before casting.

src/main/kotlin/org/opensearch/commons/alerting/model/MonitorMetadata.kt [40]

-sourceToQueryIndexMapping = (sin.readMap()?.toMutableMap() ?: mutableMapOf()) as MutableMap<String, String>
+sourceToQueryIndexMapping = sin.readMap()?.entries
+    ?.associate { (k, v) -> (k as? String ?: throw IllegalStateException("Invalid key type")) to (v as? String ?: throw IllegalStateException("Invalid value type")) }
+    ?.toMutableMap() ?: mutableMapOf()
Suggestion importance[1-10]: 4

__

Why: While the unsafe cast to MutableMap<String, String> could theoretically cause a ClassCastException, the suggestion adds significant complexity. The proposed validation is overly verbose for what should be a straightforward deserialization, though it does address a legitimate type safety concern.

Low
Validate types before unsafe cast

The unsafe cast to Map<String, DocumentLevelTriggerRunResult> can fail at runtime if
the map contains unexpected types. Since readMap with custom deserializers should
return the correct type, verify the deserializer functions return the expected types
or add runtime type validation before casting.

src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt [36-37]

-triggerResults = (sin.readMap(StreamInput::readString, DocumentLevelTriggerRunResult::readFrom) ?: mapOf())
-    as Map<String, DocumentLevelTriggerRunResult>,
+triggerResults = sin.readMap(StreamInput::readString, DocumentLevelTriggerRunResult::readFrom)
+    ?.mapValues { it.value as? DocumentLevelTriggerRunResult ?: throw IllegalStateException("Invalid trigger result type") }
+    ?: mapOf(),
Suggestion importance[1-10]: 3

__

Why: The suggestion addresses a potential runtime issue with unsafe casting, but the readMap with custom deserializers (StreamInput::readString, DocumentLevelTriggerRunResult::readFrom) should already ensure type safety. The proposed validation adds complexity without clear evidence of a real problem in the PR context.

Low

The constructor-level @Suppress("UNCHECKED_CAST") was missing even though
the deserialization expression contains an explicit unchecked cast of a
Map<String, TriggerRunResult> to Map<String, ChainedAlertTriggerRunResult>.

Kotlin requires the annotation to acknowledge the cast is intentionally
unsafe; omitting it produces a compiler warning. The cast is safe in
practice because all values were written as ChainedAlertTriggerRunResult
instances by writeTo().

Signed-off-by: thecodingshrimp <leonard.stutzer@sap.com>
@thecodingshrimp

thecodingshrimp commented Jun 9, 2026

Copy link
Copy Markdown
Author

Thanks for the automated review. Addressing each flagged item (via claude):

Unsafe cast: `WorkflowRunResult.kt` → `Map<String, ChainedAlertTriggerRunResult>`

The bot suggested switching to the typed `readMap(StreamInput::readString, ChainedAlertTriggerRunResult::readFrom)` overload to avoid the unchecked cast.

This is not viable without also changing the write side — the existing `out.writeMap(triggerResults)` uses the generic (type-tagged) wire format, while the typed `readMap` overload expects the type-free format written by `out.writeMap(map, keyWriter, valueWriter)`. Changing both sides in the same PR would introduce a wire-format break for rolling upgrades. The wire format for this map was established in its original introduction; preserving it is necessary.

What I've done instead (commit 9e0e029): added the missing `@Suppress("UNCHECKED_CAST")` on the constructor, which was inadvertently omitted. The cast is safe because the values were serialized as `ChainedAlertTriggerRunResult` instances by `writeTo()`.

Unsafe cast: `MonitorMetadata.kt` → `MutableMap<String, String>`

The suggestion to add per-entry type validation via `as? String ?: throw IllegalStateException` is over-defensive. The data at this position was always written by `MonitorMetadata.writeTo()` which casts `sourceToQueryIndexMapping: MutableMap<String, String>` down to `MutableMap<String, Any>` for the generic writer. The types are guaranteed by the write side. Adding entry-level type assertions would obscure a straightforward deserialization with complexity that adds no real value.

Unsafe cast: `DocLevelMonitorFanOutResponse.kt` → `Map<String, DocumentLevelTriggerRunResult>`

The suggestion to replace the cast with `.mapValues { it.value as? DocumentLevelTriggerRunResult ?: throw ... }` is redundant: `readMap(StreamInput::readString, DocumentLevelTriggerRunResult::readFrom)` already guarantees each value was deserialized via `DocumentLevelTriggerRunResult::readFrom`. A subsequent runtime check on what the typed deserializer just produced adds only overhead.

In summary: the flagged casts are all inherently unchecked due to JVM type erasure, but each is provably safe given the matching write side. The only actionable item was the missing `@Suppress` annotation in `WorkflowRunResult`.

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 9e0e029

…tion

StreamInput.readMap() may return Collections.emptyMap() (immutable) for
zero-size maps. Before this fix, several deserialization constructors
assumed the result was always mutable, causing ClassCastException or
UnsupportedOperationException at runtime.

Tests cover the empty-map (regression) path for all six fixed call sites:
- Monitor.uiMetadata
- MonitorMetadata.lastRunContext + sourceToQueryIndexMapping
- IndexExecutionContext.lastRunContext + updatedLastRunContext
- DocLevelMonitorFanOutResponse.lastRunContexts
- WorkflowRunResult.triggerResults
- Alert.queryResults (each element map)

Each test serializes an object with an empty map via writeTo(), deserializes
via the StreamInput constructor, and asserts the result is correct and the
map is mutable.

Signed-off-by: thecodingshrimp <leonard.stutzer@sap.com>
@thecodingshrimp

Copy link
Copy Markdown
Author

Added regression tests in (commit 504f10c) covering all six fixed call sites. Each test exercises the empty-map path: serializes via writeTo(), deserializes via the StreamInput constructor, and asserts the round-trip succeeds and the result map is mutable.

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 504f10c

executionId = sin.readString(),
monitorId = sin.readString(),
lastRunContexts = sin.readMap()!! as MutableMap<String, Any>,
lastRunContexts = sin.readMap()?.toMutableMap() ?: mutableMapOf(),

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.

@thecodingshrimp What do you think about using kotlin extensions here? Something like:

package org.opensearch.commons.alerting.util

import org.opensearch.core.common.io.stream.StreamInput
import org.opensearch.core.common.io.stream.Writeable

/**
 * Reads a map written by `StreamOutput.writeMap` and always returns a mutable map.
 *
 * Why this exists:
 * `StreamInput.readMap()` only guarantees the result implements `java.util.Map`. Its
 * Javadoc states that a non-empty result will be mutable, but an empty result *might*
 * be immutable (`Collections.emptyMap()`). This extension guarantees the returned
 * map is mutable.
 */
@Suppress("UNCHECKED_CAST")
fun StreamInput.readMapAsMutableMap(): MutableMap<String, Any> {
    val map = this.readMap() ?: return mutableMapOf()
    return if (map is MutableMap<*, *>) {
        map as MutableMap<String, Any>
    } else {
        // Immutable (e.g. Collections.emptyMap()) or unknown type: copy every entry
        // into a fresh mutable map.
        LinkedHashMap(map) as MutableMap<String, Any>
    }
}

/**
 * Typed variant for maps written with explicit key/value readers
 * (`StreamOutput.writeMap(map, keyWriter, valueWriter)`).
 */
fun <K, V> StreamInput.readMapAsMutableMap(
    keyReader: Writeable.Reader<K>,
    valueReader: Writeable.Reader<V>
): MutableMap<K, V> {
    val map = this.readMap(keyReader, valueReader) ?: return mutableMapOf()
    return if (map is MutableMap<K, V>) {
        map
    } else {
        LinkedHashMap(map)
    }
}

Then all the call sites that need a mutable map from StreamInput can use one of these methods.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the suggestion — agreed that centralizing this in an extension is the right call. I went with a cast-free version to avoid introducing a new @Suppress:

fun StreamInput.readMapAsMutableMap(): MutableMap<String, Any> =
    readMap()?.toMutableMap() ?: mutableMapOf()

This is placed in util/StreamInputExtensions.kt and used across all the fixed call sites. The is MutableMap<*, *> branch check in your proposal avoids an unnecessary copy for non-empty maps, but toMutableMap() is safe and keeps the helper annotation-free. I also extended the fix to cover the remaining suppressWarning(sin.readMap()) calls across the TriggerRunResult family and deleted the now-dead suppressWarning helpers.

thecodingshrimp and others added 4 commits June 10, 2026 14:17
- Add StreamInput.readMapAsMutableMap() extension (cast-free, no @Suppress)
- Replace suppressWarning(sin.readMap()) in MonitorRunResult.kt with extension
- Narrow @Suppress("UNCHECKED_CAST") to specific cast line in DocLevelMonitorFanOutResponse.kt
- Add regression tests for MonitorRunResult/InputRunResults empty-map paths
StreamInput.readMap(Reader, Reader) returns Collections.emptyMap() for
zero-size maps, same as the untyped overload. Replace with an explicit
empty-check that returns mutableMapOf(), and wrap non-empty results in
HashMap to guarantee mutability. The @Suppress("UNCHECKED_CAST") is
retained narrowly on the cast from TriggerRunResult to
DocumentLevelTriggerRunResult, which is unavoidable because readFrom
declares TriggerRunResult as its return type. Add regression test for
the empty triggerResults path.
- Fix QueryLevelTriggerRunResult, ClusterMetricsTriggerRunResult,
  ChainedAlertTriggerRunResult, BucketLevelTriggerRunResult: replace
  sin.readMap() as MutableMap cast with sin.readMapAsMutableMap()
- Fix ActionRunResult.output: replace suppressWarning(sin.readMap())
  with sin.readMapAsMutableMap(); delete now-unused suppressWarning helper
- Delete dead suppressWarning helpers in TriggerRunResult and Workflow
- Add regression tests for all fixed deserialization paths
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 02a9faf

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix ClassCastException from immutable map returned by StreamInput.readMap()

2 participants