From 135d519fcac0fe0a80def3cda4168d5c3e195cb1 Mon Sep 17 00:00:00 2001 From: Harish <140232061+perhapsmaple@users.noreply.github.com> Date: Sun, 1 Feb 2026 17:19:56 +0530 Subject: [PATCH 1/5] Fix RuntimeError on dictionary size change during iteration Signed-off-by: Harish <140232061+perhapsmaple@users.noreply.github.com> --- .../sdk/metrics/_internal/metric_reader_storage.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/metric_reader_storage.py b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/metric_reader_storage.py index f5121811ebc..317fda0b420 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/metric_reader_storage.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/metrics/_internal/metric_reader_storage.py @@ -143,10 +143,14 @@ def collect(self) -> Optional[MetricsData]: InstrumentationScope, ScopeMetrics ] = {} + instrument_matches_snapshot = list( + self._instrument_view_instrument_matches.items() + ) + for ( instrument, view_instrument_matches, - ) in self._instrument_view_instrument_matches.items(): + ) in instrument_matches_snapshot: aggregation_temporality = self._instrument_class_temporality[ instrument.__class__ ] From 011ca2faf5e0d5620eafdea8427958c884e56782 Mon Sep 17 00:00:00 2001 From: Harish <140232061+perhapsmaple@users.noreply.github.com> Date: Sat, 7 Feb 2026 12:39:53 +0530 Subject: [PATCH 2/5] Add test for dictionary iteration race condition in metrics collection Signed-off-by: Harish <140232061+perhapsmaple@users.noreply.github.com> --- .../metrics/test_metric_reader_storage.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py b/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py index 7c9484b9177..4ee3afe859b 100644 --- a/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py +++ b/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py @@ -14,6 +14,7 @@ # pylint: disable=protected-access,invalid-name +import threading from logging import WARNING from time import time_ns from unittest.mock import MagicMock, Mock, patch @@ -278,6 +279,42 @@ def send_measurement(): # _ViewInstrumentMatch constructor should have only been called once self.assertEqual(mock_view_instrument_match_ctor.call_count, 1) + def test_race_collect_with_new_instruments(self): + storage = MetricReaderStorage( + SdkConfiguration( + exemplar_filter=Mock(), + resource=Mock(), + metric_readers=(), + views=(View(instrument_name="test"),), + ), + MagicMock( + **{ + "__getitem__.return_value": AggregationTemporality.CUMULATIVE + } + ), + MagicMock(**{"__getitem__.return_value": DefaultAggregation()}), + ) + + counter = _Counter("counter", Mock(), Mock()) + storage.consume_measurement( + Measurement(1, time_ns(), counter, Context()) + ) + + view_instrument_match = storage._instrument_view_instrument_matches[counter][0] + original_collect = view_instrument_match.collect + + new_counter = _Counter("new_counter", Mock(), Mock()) + + # Patch collect() to add a new counter during iteration + def collect_with_modification(*args, **kwargs): + storage._instrument_view_instrument_matches[new_counter] = [] + return original_collect(*args, **kwargs) + + view_instrument_match.collect = collect_with_modification + storage.collect() + + self.assertIn(new_counter, storage._instrument_view_instrument_matches) + @patch( "opentelemetry.sdk.metrics._internal." "metric_reader_storage._ViewInstrumentMatch" From 3c1b268e470045ced81f05b40f5f3f7aca6daf21 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Thu, 12 Feb 2026 10:21:42 +0100 Subject: [PATCH 3/5] Apply suggestion from @xrmx --- opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py | 1 - 1 file changed, 1 deletion(-) diff --git a/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py b/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py index 4ee3afe859b..d42d85127cd 100644 --- a/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py +++ b/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py @@ -14,7 +14,6 @@ # pylint: disable=protected-access,invalid-name -import threading from logging import WARNING from time import time_ns from unittest.mock import MagicMock, Mock, patch From 0c2998eb5816515d7b4f173173bc47904fa55026 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Thu, 12 Feb 2026 10:36:10 +0100 Subject: [PATCH 4/5] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f125b47e997..cb4455efc32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4862](https://github.com/open-telemetry/opentelemetry-python/pull/4862)) - `opentelemetry-exporter-otlp-proto-http`: fix retry logic and error handling for connection failures in trace, metric, and log exporters ([#4709](https://github.com/open-telemetry/opentelemetry-python/pull/4709)) +- `opentelemetry-sdk`: avoid RuntimeError during iteration of view instrument match dictionary in MetricReaderStorage.collect() + ([#4891](https://github.com/open-telemetry/opentelemetry-python/pull/4891)) ## Version 1.39.0/0.60b0 (2025-12-03) From 50634a105f4abfd152f727b9b21034db21c17d3b Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Thu, 12 Feb 2026 12:16:11 +0100 Subject: [PATCH 5/5] Apply suggestion from @xrmx --- opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py b/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py index d42d85127cd..ec1456ae84c 100644 --- a/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py +++ b/opentelemetry-sdk/tests/metrics/test_metric_reader_storage.py @@ -299,7 +299,9 @@ def test_race_collect_with_new_instruments(self): Measurement(1, time_ns(), counter, Context()) ) - view_instrument_match = storage._instrument_view_instrument_matches[counter][0] + view_instrument_match = storage._instrument_view_instrument_matches[ + counter + ][0] original_collect = view_instrument_match.collect new_counter = _Counter("new_counter", Mock(), Mock())