Skip to content

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

Open
thecodingshrimp wants to merge 1 commit 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 1 commit 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

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 6d3e68a)

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

Serialization order changed

The deserialization constructor DocLevelMonitorFanOutResponse(StreamInput) was reordered: triggerResults is now read before exception, whereas the previous order read inputResults, then triggerResults, then exception. The visual order in the new constructor (via named args) does not control the actual read order — Kotlin executes the argument expressions in source order. Compare with writeTo: it writes lastRunContexts, then inputResults, then triggerResults, then exception. The new constructor evaluates lastRunContexts, inputResults, triggerResults, exception in that source order, which matches. However, verify the source-order matches writeTo exactly; if a reader and older writer are on the wire together across versions, mismatched order corrupts the stream. Since this is not version-guarded, any accidental reordering breaks cross-version transport.

@Throws(IOException::class)
constructor(sin: StreamInput) : this(
    nodeId = sin.readString(),
    executionId = sin.readString(),
    monitorId = sin.readString(),
    lastRunContexts = sin.readMapAsMutableMap() as MutableMap<String, Any>,
    inputResults = InputRunResults.readFrom(sin),
    triggerResults = readTriggerResults(sin),
    exception = sin.readException()
)
Possible wire-format change

writeTo previously wrote sourceToQueryIndexMapping as MutableMap<String, Any> and now writes sourceToQueryIndexMapping as Map<String, Any>. If this changes the resolved writeMap overload (e.g., from a typed writer to writeGenericValue-based writer), the byte layout changes and is not version-guarded, breaking mixed-version clusters. Confirm both casts resolve to the same StreamOutput.writeMap overload; if not, the change needs a version guard with both old and new branches on write and read sides.

out.writeMap(lastRunContext)
out.writeMap(sourceToQueryIndexMapping as Map<String, Any>)

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 6d3e68a

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Always return a mutable map from helper

The cast raw as Map<String, DocumentLevelTriggerRunResult> is an unchecked generic
cast that is a no-op at runtime, but HashMap(raw) may still produce an
immutable-behaving wrapper only if raw itself is immutable. Since readMap with
valueReader already returns a mutable map when non-empty, the wrap is fine, but
consider returning the HashMap directly for the empty case too to keep the return
type consistent and always mutable. Also, the callsite casts the returned Map to
MutableMap, so returning MutableMap directly would prevent a future
ClassCastException.

src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt [88-93]

-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()
     @Suppress("UNCHECKED_CAST")
     return HashMap(raw as Map<String, DocumentLevelTriggerRunResult>)
 }
Suggestion importance[1-10]: 5

__

Why: Reasonable improvement to return type consistency and safety, since callers may cast to MutableMap. Minor impact as the current code already handles the empty case correctly.

Low
Possible issue
Avoid unsafe cast to typed mutable map

xcp.map() returns Map<String, Any>, so casting a MutableMap<String, Any> to
MutableMap<String, String> is an unchecked cast that will succeed at the cast site
but throw ClassCastException later when a non-String value is read out. Convert
values explicitly to String to fail fast and preserve type safety.

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

-SOURCE_TO_QUERY_INDEX_MAP_FIELD -> sourceToQueryIndexMapping = (xcp.map()?.toMutableMap() ?: mutableMapOf()) as MutableMap<String, String>
+SOURCE_TO_QUERY_INDEX_MAP_FIELD -> sourceToQueryIndexMapping = xcp.map()?.mapValues { it.value.toString() }?.toMutableMap() ?: mutableMapOf()
Suggestion importance[1-10]: 4

__

Why: Valid point about unchecked cast deferring failure, but the previous code had the same issue and values are expected to be strings from serialization. Marginal safety improvement.

Low
Convert map values instead of unchecked cast

Casting MutableMap<String, Any> to Map<String, String> is unchecked; a non-String
value would only fail later at usage. Convert values with .toString() (or a proper
type check) to keep the map's declared value type honest and avoid deferred
ClassCastException.

src/main/kotlin/org/opensearch/commons/alerting/aggregation/bucketselectorext/BucketSelectorExtAggregationBuilder.kt [46]

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

__

Why: Similar to suggestion 2: identifies an unchecked cast, but this preserves the pre-existing behavior and forcibly stringifying values may mask real data issues. Minor improvement.

Low

Previous suggestions

Suggestions up to commit a2aa8ae
CategorySuggestion                                                                                                                                    Impact
General
Fix return type to allow nullable values

StreamInput.readMap() returns Map<String, Object> (nullable Any?). Declaring the
return as MutableMap<String, Any> performs an implicit unchecked cast that could
produce values with null at runtime, causing later NPEs. Consider returning
MutableMap<String, Any?> to match the actual signature, or filter/assert non-null
values.

src/main/kotlin/org/opensearch/commons/alerting/util/StreamInputExtensions.kt [15-16]

-fun StreamInput.readMapAsMutableMap(): MutableMap<String, Any> =
+fun StreamInput.readMapAsMutableMap(): MutableMap<String, Any?> =
     readMap()?.toMutableMap() ?: mutableMapOf()
Suggestion importance[1-10]: 5

__

Why: Valid concern: readMap() returns Map<String, Object> where values may be null, so declaring MutableMap<String, Any> masks nullability. However, many call sites cast to specific mutable types, so changing this could ripple.

Low
Always return a mutable copy to simplify logic

The current code returns a plain mutableMapOf() on empty and a HashMap copy on
non-empty. Since tests cast this to MutableMap, and readMap may return an immutable
map for the empty case, simply always wrapping in a HashMap (or LinkedHashMap to
preserve order) would be simpler and safer, avoiding the branching and ensuring
consistent mutability behavior.

src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt [88-93]

 private fun readTriggerResults(sin: StreamInput): Map<String, DocumentLevelTriggerRunResult> {
     val raw = sin.readMap(StreamInput::readString, DocumentLevelTriggerRunResult::readFrom)
-    if (raw.isEmpty()) return mutableMapOf()
     @Suppress("UNCHECKED_CAST")
-    return HashMap(raw as Map<String, DocumentLevelTriggerRunResult>)
+    return LinkedHashMap(raw as Map<String, DocumentLevelTriggerRunResult>)
 }
Suggestion importance[1-10]: 4

__

Why: Minor simplification; both approaches produce a mutable map. The improvement is stylistic rather than functional.

Low
Safely cast map values to correct type

The cast as Map<String, String> on a MutableMap<String, Any?> is unchecked and won't
fail at cast time, but any later access assuming String values could still
ClassCastException if the stream contains non-String values. Consider mapping values
to Strings explicitly to preserve type safety, matching the field declaration.

src/main/kotlin/org/opensearch/commons/alerting/aggregation/bucketselectorext/BucketSelectorExtAggregationBuilder.kt [45-46]

-@Suppress("UNCHECKED_CAST")
-bucketsPathsMap = (sin.readMap()?.toMutableMap() ?: mutableMapOf()) as Map<String, String>
+bucketsPathsMap = sin.readMap()?.mapValuesTo(mutableMapOf()) { it.value as String } ?: mutableMapOf()
Suggestion importance[1-10]: 3

__

Why: The original code has always used an unchecked cast; the suggestion is more defensive but changes semantics slightly and is not directly related to the PR's fix.

Low
Suggestions up to commit b41f66d
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix inconsistent map type cast

The cast as Map<String, String> may cause a ClassCastException at runtime when
values are actually Any, and assigning Map<String, String> to a MutableMap<String,
String> field is inconsistent. Since the field type is MutableMap<String, String>,
cast the mutable map directly and cast entries to String.

src/main/kotlin/org/opensearch/commons/alerting/aggregation/bucketselectorext/BucketSelectorExtAggregationBuilder.kt [46]

-bucketsPathsMap = (sin.readMap()?.toMutableMap() ?: mutableMapOf()) as Map<String, String>
+@Suppress("UNCHECKED_CAST")
+bucketsPathsMap = (sin.readMap()?.toMutableMap() ?: mutableMapOf<String, Any>()) as MutableMap<String, String>
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies that the cast as Map<String, String> is inconsistent with the field's declared MutableMap<String, String> type. The improved code makes the mutability contract explicit, though the runtime behavior is largely equivalent since the underlying map is already mutable.

Low
General
Return mutable map explicitly

The raw as Map<String, DocumentLevelTriggerRunResult> cast is unnecessary since
readMap with typed readers already returns Map<String,
DocumentLevelTriggerRunResult>. More importantly, HashMap(raw) copies to a mutable
map but the return type Map doesn't advertise mutability; consider returning
MutableMap directly to make the mutability contract explicit for callers that cast
to MutableMap.

src/main/kotlin/org/opensearch/commons/alerting/action/DocLevelMonitorFanOutResponse.kt [88-93]

-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()
-    @Suppress("UNCHECKED_CAST")
-    return HashMap(raw as Map<String, DocumentLevelTriggerRunResult>)
+    return HashMap(raw)
 }
Suggestion importance[1-10]: 4

__

Why: Correctly notes the unnecessary cast since readMap with typed readers already returns the proper type. Returning MutableMap explicitly is a minor readability/API clarity improvement but doesn't change functionality significantly.

Low
Handle nullable map values safely

StreamInput.readMap() returns Map<String, Any> where values may be null (the
underlying generic map allows nulls). Declaring the return as MutableMap<String,
Any> while toMutableMap() on a Map<String, Any?> produces MutableMap<String, Any?>
may silently hide nulls that violate the non-null contract. Consider making the
value type nullable or filtering nulls to avoid downstream NPEs.

src/main/kotlin/org/opensearch/commons/alerting/util/StreamInputExtensions.kt [15-16]

 fun StreamInput.readMapAsMutableMap(): MutableMap<String, Any> =
-    readMap()?.toMutableMap() ?: mutableMapOf()
+    readMap()?.filterValues { it != null }?.mapValues { it.value!! }?.toMutableMap() ?: mutableMapOf()
Suggestion importance[1-10]: 3

__

Why: The concern about null values is theoretical; the original code preserves existing behavior of sin.readMap(). Filtering nulls could actually change semantics and hide data loss, so the suggested fix may not be desirable.

</result>

</details></details></td><td align=center>Low

</td></tr></tr></tbody></table>

</details>
<details><summary>Suggestions up to commit e518795</summary>
<br><table><thead><tr><td><strong>Category</strong></td><td align=left><strong>Suggestion&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </strong></td><td align=center><strong>Impact</strong></td></tr><tbody><tr><td rowspan=2>Possible issue</td>
<td>



<details><summary>Use typed reader for trigger results map</summary>

___


**<code>sin.readMap()</code> returns <code>Map<String, Any></code>, but values in <code>triggerResults</code> must be <br><code>ChainedAlertTriggerRunResult</code> (a Writeable). The generic <code>readMap()</code> cannot deserialize <br>those complex objects — non-empty maps written by <code>writeTo</code> will not round-trip <br>correctly, and the unchecked cast masks this. Use the typed overload <br><code>sin.readMap(StreamInput::readString, ::ChainedAlertTriggerRunResult)</code> and defensively <br>copy into a mutable map.**

[src/main/kotlin/org/opensearch/commons/alerting/model/WorkflowRunResult.kt [38]](https://github.com/opensearch-project/common-utils/pull/968/files#diff-e97cc9df46b267ae869138da97b3e8f1ced7943dd508862c29a5969403ab3258R38-R38)

```diff
-triggerResults = (sin.readMap()?.toMutableMap() ?: mutableMapOf()) as Map<String, ChainedAlertTriggerRunResult>
+triggerResults = run {
+    val raw = sin.readMap(StreamInput::readString, ::ChainedAlertTriggerRunResult)
+    if (raw.isEmpty()) mutableMapOf() else HashMap(raw)
+}
Suggestion importance[1-10]: 7

__

Why: Legitimate concern: the untyped readMap() cannot properly deserialize ChainedAlertTriggerRunResult Writeable values, so non-empty maps won't round-trip correctly. The test added in the PR even acknowledges this limitation, suggesting this is a real gap.

Medium
Deserialize trigger results with typed reader

Using the untyped readMap() here cannot reconstruct concrete TriggerRunResult
subclasses (values are read as generic Any), so the unchecked cast to Map<String,
TriggerResult> will produce a map whose values are not the expected type, causing
deferred ClassCastException on access. Deserialize with the appropriate typed reader
(matching how writeTo serializes trigger results) instead of the generic map reader.

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

-sin.readMapAsMutableMap() as Map<String, TriggerResult> // triggerResults
+sin.readMap(StreamInput::readString) { s -> TriggerRunResult.readFrom(s) as TriggerResult }
+    .let { if (it.isEmpty()) mutableMapOf() else HashMap(it) }
Suggestion importance[1-10]: 6

__

Why: Valid concern about untyped map deserialization being unable to reconstruct concrete TriggerRunResult subclasses. However, this pattern pre-existed and the PR only changed to defensive copying, so it's a legitimate but pre-existing issue.

Low
General
Avoid unchecked cast for string-value map

Casting a MutableMap<String, Any> to MutableMap<String, String> is an unchecked cast
that can produce heap pollution and defer ClassCastException until later reads.
Prefer explicitly copying entries with a String value cast, or use
readMap(StreamInput::readString, StreamInput::readString) if the wire format is
known to contain strings.

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

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

__

Why: Valid concern about unchecked casts causing deferred ClassCastException. The improvement is reasonable but the pre-existing code also had this pattern, so it's a minor code-quality improvement rather than a critical bug fix.

Low
Safely copy trigger results into a mutable map

The non-empty branch returns a HashMap wrapping a cast of raw, but readMap(...,
readFrom) returns a Map<String, TriggerRunResult> whose values are the base type.
The unchecked cast to Map<String, DocumentLevelTriggerRunResult> is unsafe and may
fail at value-access time in some JVMs; more importantly, mapping the values
explicitly avoids relying on ClassCastException-deferred casts. Build the result by
iterating and casting each entry, or return a defensive copy that is always mutable
regardless of size.

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)
+    for ((k, v) in raw) {
+        result[k] = v as DocumentLevelTriggerRunResult
+    }
+    return result
 }
Suggestion importance[1-10]: 4

__

Why: The suggestion is a minor style improvement; the original code's unchecked cast is functionally equivalent since readFrom already constructs DocumentLevelTriggerRunResult instances. The proposed change is slightly more defensive but not critical.

Low
Suggestions up to commit e518795
CategorySuggestion                                                                                                                                    Impact
Possible issue
Avoid unchecked cast on typed map values

Casting a MutableMap<String, Any?> to MutableMap<String, String> is an unchecked
cast that can cause ClassCastException at value-access time if any value is not a
String. Since readMap() returns Any? values, use mapValuesTo (or explicit
conversion) to safely produce a MutableMap<String, String> and fail early on
unexpected values.

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

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

__

Why: The suggestion correctly points out that the unchecked cast to MutableMap<String, String> could defer ClassCastException to access time. Using mapValuesTo provides earlier failure but is a minor safety/style improvement, not a critical bug fix.

Low
General
Always wrap deserialized map for consistency

The empty-map branch returns mutableMapOf() while the non-empty branch returns
HashMap(...); both should consistently return a mutable map. More importantly, if
readMap ever returns an immutable empty map on the non-empty path in some edge case,
wrapping in HashMap is only done conditionally. Simplify by always wrapping in a
HashMap to guarantee mutability and avoid the branch, and note the raw generic type
also loses value-type checking.

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

 private fun readTriggerResults(sin: StreamInput): Map<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>)
 }
Suggestion importance[1-10]: 3

__

Why: The simplification is minor; HashMap of an empty map already yields a mutable empty map, so removing the branch is a stylistic cleanup with negligible functional impact.


"""

</details></details></td><td align=center>Low

</td></tr></tr></tbody></table>

</details>
<details><summary>Suggestions up to commit 02a9faf</summary>
<br><table><thead><tr><td><strong>Category</strong></td><td align=left><strong>Suggestion&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </strong></td><td align=center><strong>Impact</strong></td></tr><tbody><tr><td rowspan=2>Possible issue</td>
<td>



<details><summary>Use typed readMap for Writeable values</summary>

___


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

[src/main/kotlin/org/opensearch/commons/alerting/model/WorkflowRunResult.kt [38]](https://github.com/opensearch-project/common-utils/pull/968/files#diff-e97cc9df46b267ae869138da97b3e8f1ced7943dd508862c29a5969403ab3258R38-R38)

```diff
-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

@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

@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.

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 02a9faf

@thecodingshrimp
thecodingshrimp force-pushed the fix/monitor-uimetadata-immutable-map-cast branch 2 times, most recently from dbb64d1 to e518795 Compare August 19, 2026 15:00
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit e518795

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit e518795

@thecodingshrimp
thecodingshrimp force-pushed the fix/monitor-uimetadata-immutable-map-cast branch from e518795 to b41f66d Compare August 19, 2026 15:02
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit b41f66d

@thecodingshrimp
thecodingshrimp force-pushed the fix/monitor-uimetadata-immutable-map-cast branch from b41f66d to a2aa8ae Compare August 21, 2026 13:13
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit a2aa8ae

Signed-off-by: thecodingshrimp <leonard.stutzer@sap.com>
@thecodingshrimp
thecodingshrimp force-pushed the fix/monitor-uimetadata-immutable-map-cast branch from a2aa8ae to 6d3e68a Compare September 2, 2026 15:18
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 6d3e68a

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()

3 participants