@@ -13,9 +13,11 @@ namespace OpenTelemetry.Metrics;
1313/// </summary>
1414public abstract partial class MetricReader
1515{
16- private readonly HashSet < string > metricStreamNames = new ( StringComparer . OrdinalIgnoreCase ) ;
16+ private readonly Dictionary < string , int > metricStreamNames = new ( StringComparer . OrdinalIgnoreCase ) ;
1717 private readonly ConcurrentDictionary < MetricStreamIdentity , Metric ? > instrumentIdentityToMetric = new ( ) ;
1818 private readonly Lock instrumentCreationLock = new ( ) ;
19+ private readonly ConcurrentQueue < int > availableMetricIndices = new ( ) ;
20+
1921 private int metricLimit ;
2022 private int cardinalityLimit ;
2123 private Metric ? [ ] metrics = [ ] ;
@@ -28,11 +30,11 @@ internal static void DeactivateMetric(Metric metric)
2830 {
2931 if ( metric . Active )
3032 {
31- // TODO : This will cause the metric to be removed from the storage
32- // array during the next collect/export. If this happens often we
33- // will run out of storage. Would it be better instead to set the
34- // end time on the metric and keep it around so it can be
35- // reactivated?
33+ // Note : This causes the metric to be removed from the storage array
34+ // during the next collect/export. The freed storage slot is then
35+ // reused for a subsequently published instrument (see
36+ // TryReserveMetricIndex), so repeatedly creating and disposing
37+ // meters/instruments does not exhaust the metric storage.
3638 metric . Active = false ;
3739
3840 OpenTelemetrySdkEventSource . Log . MetricInstrumentDeactivated (
@@ -67,8 +69,7 @@ internal virtual List<Metric> AddMetricWithNoViews(Instrument instrument)
6769 return [ existingMetric ] ;
6870 }
6971
70- var index = ++ this . metricIndex ;
71- if ( index >= this . metricLimit )
72+ if ( ! this . TryReserveMetricIndex ( out var index ) )
7273 {
7374 OpenTelemetrySdkEventSource . Log . MetricInstrumentIgnored ( metricStreamIdentity . InstrumentName , metricStreamIdentity . MeterName , "Maximum allowed Metric streams for the provider exceeded." , "Use MeterProviderBuilder.AddView to drop unused instruments. Or use MeterProviderBuilder.SetMaxMetricStreams to configure MeterProvider to allow higher limit." ) ;
7475 return [ ] ;
@@ -86,6 +87,9 @@ internal virtual List<Metric> AddMetricWithNoViews(Instrument instrument)
8687 }
8788 catch ( NotSupportedException nse )
8889 {
90+ // The reserved slot was never populated, so return it for reuse.
91+ this . availableMetricIndices . Enqueue ( index ) ;
92+
8993 // TODO: This allocates string even if none listening.
9094 // Could be improved with separate Event.
9195 // Also the message could call out what Instruments
@@ -158,8 +162,7 @@ internal virtual List<Metric> AddMetricWithViews(Instrument instrument, List<Met
158162 continue ;
159163 }
160164
161- var index = ++ this . metricIndex ;
162- if ( index >= this . metricLimit )
165+ if ( ! this . TryReserveMetricIndex ( out var index ) )
163166 {
164167 OpenTelemetrySdkEventSource . Log . MetricInstrumentIgnored ( metricStreamIdentity . InstrumentName , metricStreamIdentity . MeterName , "Maximum allowed Metric streams for the provider exceeded." , "Use MeterProviderBuilder.AddView to drop unused instruments. Or use MeterProviderBuilder.SetMaxMetricStreams to configure MeterProvider to allow higher limit." ) ;
165168 }
@@ -221,15 +224,25 @@ private bool TryGetExistingMetric(in MetricStreamIdentity metricStreamIdentity,
221224
222225 private void CreateOrUpdateMetricStreamRegistration ( in MetricStreamIdentity metricStreamIdentity )
223226 {
224- if ( ! this . metricStreamNames . Add ( metricStreamIdentity . MetricStreamName ) )
227+ if ( this . metricStreamNames . TryGetValue ( metricStreamIdentity . MetricStreamName , out var count ) )
225228 {
226- // TODO: If a metric is deactivated and then reactivated we log the
227- // same warning as if it was a duplicate.
229+ // TODO: This can be a false positive. The registration is only
230+ // released when the metric is removed during a collection cycle.
231+ // If an instrument is deactivated and an instrument with the same
232+ // stream name is re-created before the next collect, the stale
233+ // registration is still present and this logs a duplicate/conflict
234+ // warning even though there is no actual conflict.
228235 OpenTelemetrySdkEventSource . Log . DuplicateMetricInstrument (
229236 metricStreamIdentity . InstrumentName ,
230237 metricStreamIdentity . MeterName ,
231238 "Metric instrument has the same name as an existing one but differs by description, unit, or instrument type. Measurements from this instrument will still be exported but may result in conflicts." ,
232239 "Either change the name of the instrument or use MeterProviderBuilder.AddView to resolve the conflict." ) ;
240+
241+ this . metricStreamNames [ metricStreamIdentity . MetricStreamName ] = count + 1 ;
242+ }
243+ else
244+ {
245+ this . metricStreamNames [ metricStreamIdentity . MetricStreamName ] = 1 ;
233246 }
234247 }
235248
@@ -254,7 +267,7 @@ private Batch<Metric> GetMetricsBatch()
254267
255268 if ( ! metric . Active )
256269 {
257- this . RemoveMetric ( ref metric ) ;
270+ this . RemoveMetric ( ref metric , i ) ;
258271 }
259272 }
260273 }
@@ -268,26 +281,77 @@ private Batch<Metric> GetMetricsBatch()
268281 }
269282 }
270283
271- private void RemoveMetric ( ref Metric ? metric )
284+ private void RemoveMetric ( ref Metric ? metric , int index )
272285 {
273- // TODO: This logic removes the metric. If the same
274- // metric is published again we will create a new metric
275- // for it. If this happens often we will run out of
276- // storage. Instead, should we keep the metric around
277- // and set a new start time + reset its data if it comes
278- // back?
279-
280286 OpenTelemetrySdkEventSource . Log . MetricInstrumentRemoved ( metric ! . Name , metric . MeterName ) ;
281287
282- // Note: This is using TryUpdate and NOT TryRemove because there is a
283- // race condition. If a metric is deactivated and then reactivated in
284- // the same collection cycle
285- // instrumentIdentityToMetric[metric.InstrumentIdentity] may already
286- // point to the new activated metric and not the old deactivated one.
287- this . instrumentIdentityToMetric . TryUpdate ( metric . InstrumentIdentity , null , metric ) ;
288+ // Note: This removes the entry (key and value) so the dictionary does
289+ // not grow without bound as instruments are repeatedly created and
290+ // disposed. A value-matching removal is used to guard against a race
291+ // condition: if a metric is deactivated and then reactivated in the
292+ // same collection cycle, instrumentIdentityToMetric[metric.InstrumentIdentity]
293+ // may already point to the new (active) metric. In that case the entry's
294+ // value no longer equals the old metric, the removal is a no-op, and the
295+ // new metric is retained.
296+ var item = new KeyValuePair < MetricStreamIdentity , Metric ? > ( metric . InstrumentIdentity , metric ) ;
297+ #if NET
298+ this . instrumentIdentityToMetric . TryRemove ( item ) ;
299+ #else
300+ ICollection < KeyValuePair < MetricStreamIdentity , Metric ? > > instrumentIdentityToMetric = this . instrumentIdentityToMetric ;
301+ instrumentIdentityToMetric . Remove ( item ) ;
302+ #endif
303+
304+ // Release the stream-name registration for this metric. When the last
305+ // metric using the name is removed the entry is dropped so the
306+ // dictionary does not grow without bound as instruments are repeatedly
307+ // created and disposed with recycled storage slots.
308+ var metricStreamName = metric . InstrumentIdentity . MetricStreamName ;
309+ lock ( this . instrumentCreationLock )
310+ {
311+ if ( this . metricStreamNames . TryGetValue ( metricStreamName , out var count ) )
312+ {
313+ if ( count <= 1 )
314+ {
315+ this . metricStreamNames . Remove ( metricStreamName ) ;
316+ }
317+ else
318+ {
319+ this . metricStreamNames [ metricStreamName ] = count - 1 ;
320+ }
321+ }
322+ }
288323
289324 // Note: metric is a reference to the array storage so
290325 // this clears the metric out of the array.
291326 metric = null ;
327+
328+ // Make the freed slot available for reuse by a future instrument. This
329+ // must happen after the slot has been cleared above so that a concurrent
330+ // AddMetricWith*Views call observes a null slot before writing to it.
331+ this . availableMetricIndices . Enqueue ( index ) ;
332+ }
333+
334+ private bool TryReserveMetricIndex ( out int index )
335+ {
336+ // Note: This is always called while holding instrumentCreationLock, so
337+ // access to metricIndex does not need additional synchronization.
338+
339+ // Reuse a slot freed by a previously removed (deactivated) metric, if
340+ // one is available, before consuming a brand new slot.
341+ if ( this . availableMetricIndices . TryDequeue ( out index ) )
342+ {
343+ return true ;
344+ }
345+
346+ var newIndex = this . metricIndex + 1 ;
347+ if ( newIndex >= this . metricLimit )
348+ {
349+ index = - 1 ;
350+ return false ;
351+ }
352+
353+ this . metricIndex = newIndex ;
354+ index = newIndex ;
355+ return true ;
292356 }
293357}
0 commit comments