Skip to content

Commit cbd289b

Browse files
authored
fix(logs): race condition in high volume logging scenarios (#4428)
1 parent f1e7e92 commit cbd289b

File tree

6 files changed

+70
-39
lines changed

6 files changed

+70
-39
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Experimental _Structured Logs_:
88
- Remove `IDisposable` from `SentryStructuredLogger`. Disposal is intended through the owning `IHub` instance. ([#4424](https://github.com/getsentry/sentry-dotnet/pull/4424))
99
- Ensure all buffered logs are sent to Sentry when the application terminates unexpectedly. ([#4425](https://github.com/getsentry/sentry-dotnet/pull/4425))
10+
- `InvalidOperationException` potentially thrown during a race condition, especially in concurrent high-volume logging scenarios ([#4428](https://github.com/getsentry/sentry-dotnet/pull/4428))
1011

1112
### Dependencies
1213

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
```
22
3-
BenchmarkDotNet v0.13.12, macOS 15.5 (24F74) [Darwin 24.5.0]
3+
BenchmarkDotNet v0.13.12, macOS 15.6 (24G84) [Darwin 24.6.0]
44
Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores
55
.NET SDK 9.0.301
66
[Host] : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD
77
DefaultJob : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD
88
99
1010
```
11-
| Method | BatchCount | OperationsPerInvoke | Mean | Error | StdDev | Gen0 | Allocated |
12-
|---------------- |----------- |-------------------- |------------:|---------:|---------:|-------:|----------:|
13-
| **EnqueueAndFlush** | **10** | **100** | **1,774.5 ns** | **7.57 ns** | **6.71 ns** | **0.6104** | **5 KB** |
14-
| **EnqueueAndFlush** | **10** | **200** | **3,468.5 ns** | **11.16 ns** | **10.44 ns** | **1.2207** | **10 KB** |
15-
| **EnqueueAndFlush** | **10** | **1000** | **17,259.7 ns** | **51.92 ns** | **46.02 ns** | **6.1035** | **50 KB** |
16-
| **EnqueueAndFlush** | **100** | **100** | **857.5 ns** | **4.21 ns** | **3.73 ns** | **0.1469** | **1.2 KB** |
17-
| **EnqueueAndFlush** | **100** | **200** | **1,681.4 ns** | **1.74 ns** | **1.63 ns** | **0.2937** | **2.41 KB** |
18-
| **EnqueueAndFlush** | **100** | **1000** | **8,302.2 ns** | **12.00 ns** | **10.64 ns** | **1.4648** | **12.03 KB** |
11+
| Method | BatchCount | OperationsPerInvoke | Mean | Error | StdDev | Gen0 | Allocated |
12+
|------------------------- |----------- |-------------------- |-------------:|------------:|------------:|-------:|----------:|
13+
| **EnqueueAndFlush** | **10** | **100** | **1,793.4 ns** | **13.75 ns** | **12.86 ns** | **0.6104** | **5 KB** |
14+
| EnqueueAndFlush_Parallel | 10 | 100 | 18,550.8 ns | 368.24 ns | 889.34 ns | 1.1292 | 9.16 KB |
15+
| **EnqueueAndFlush** | **10** | **200** | **3,679.8 ns** | **18.65 ns** | **16.53 ns** | **1.2207** | **10 KB** |
16+
| EnqueueAndFlush_Parallel | 10 | 200 | 41,246.4 ns | 508.07 ns | 475.25 ns | 1.7090 | 14.04 KB |
17+
| **EnqueueAndFlush** | **10** | **1000** | **17,239.1 ns** | **62.50 ns** | **58.46 ns** | **6.1035** | **50 KB** |
18+
| EnqueueAndFlush_Parallel | 10 | 1000 | 192,059.3 ns | 956.92 ns | 895.11 ns | 4.3945 | 37.52 KB |
19+
| **EnqueueAndFlush** | **100** | **100** | **866.7 ns** | **1.99 ns** | **1.77 ns** | **0.1469** | **1.2 KB** |
20+
| EnqueueAndFlush_Parallel | 100 | 100 | 6,714.8 ns | 100.75 ns | 94.24 ns | 0.5569 | 4.52 KB |
21+
| **EnqueueAndFlush** | **100** | **200** | **1,714.5 ns** | **3.20 ns** | **3.00 ns** | **0.2937** | **2.41 KB** |
22+
| EnqueueAndFlush_Parallel | 100 | 200 | 43,842.8 ns | 860.74 ns | 1,718.99 ns | 0.9155 | 7.51 KB |
23+
| **EnqueueAndFlush** | **100** | **1000** | **8,537.8 ns** | **9.80 ns** | **9.17 ns** | **1.4648** | **12.03 KB** |
24+
| EnqueueAndFlush_Parallel | 100 | 1000 | 313,421.4 ns | 6,159.27 ns | 6,846.01 ns | 1.9531 | 18.37 KB |

benchmarks/Sentry.Benchmarks/StructuredLogBatchProcessorBenchmarks.cs

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using BenchmarkDotNet.Attributes;
2-
using NSubstitute;
32
using Sentry.Extensibility;
43
using Sentry.Internal;
54

@@ -30,22 +29,10 @@ public void Setup()
3029
};
3130

3231
var batchInterval = Timeout.InfiniteTimeSpan;
33-
34-
var clientReportRecorder = Substitute.For<IClientReportRecorder>();
35-
clientReportRecorder
36-
.When(static recorder => recorder.RecordDiscardedEvent(Arg.Any<DiscardReason>(), Arg.Any<DataCategory>(), Arg.Any<int>()))
37-
.Throw<UnreachableException>();
38-
39-
var diagnosticLogger = Substitute.For<IDiagnosticLogger>();
40-
diagnosticLogger
41-
.When(static logger => logger.IsEnabled(Arg.Any<SentryLevel>()))
42-
.Throw<UnreachableException>();
43-
diagnosticLogger
44-
.When(static logger => logger.Log(Arg.Any<SentryLevel>(), Arg.Any<string>(), Arg.Any<Exception>(), Arg.Any<object[]>()))
45-
.Throw<UnreachableException>();
32+
var clientReportRecorder = new NullClientReportRecorder();
4633

4734
_hub = new Hub(options, DisabledHub.Instance);
48-
_batchProcessor = new StructuredLogBatchProcessor(_hub, BatchCount, batchInterval, clientReportRecorder, diagnosticLogger);
35+
_batchProcessor = new StructuredLogBatchProcessor(_hub, BatchCount, batchInterval, clientReportRecorder, null);
4936
_log = new SentryLog(DateTimeOffset.Now, SentryId.Empty, SentryLogLevel.Trace, "message");
5037
}
5138

@@ -59,10 +46,38 @@ public void EnqueueAndFlush()
5946
_batchProcessor.Flush();
6047
}
6148

49+
[Benchmark]
50+
public void EnqueueAndFlush_Parallel()
51+
{
52+
_ = Parallel.For(0, OperationsPerInvoke, (int i) =>
53+
{
54+
_batchProcessor.Enqueue(_log);
55+
});
56+
_batchProcessor.Flush();
57+
}
58+
6259
[GlobalCleanup]
6360
public void Cleanup()
6461
{
6562
_batchProcessor.Dispose();
6663
_hub.Dispose();
6764
}
6865
}
66+
67+
file sealed class NullClientReportRecorder : IClientReportRecorder
68+
{
69+
public void RecordDiscardedEvent(DiscardReason reason, DataCategory category, int quantity = 1)
70+
{
71+
// no-op
72+
}
73+
74+
public ClientReport GenerateClientReport()
75+
{
76+
throw new UnreachableException();
77+
}
78+
79+
public void Load(ClientReport report)
80+
{
81+
throw new UnreachableException();
82+
}
83+
}

src/Sentry/Internal/StructuredLogBatchBuffer.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ namespace Sentry.Internal;
88
/// <remarks>
99
/// Must be attempted to flush via <see cref="TryEnterFlushScope"/> when either the <see cref="Capacity"/> is reached,
1010
/// or when the <see cref="_timeout"/> is exceeded.
11+
/// Utilizes a <see cref="ScopedCountdownLock"/>, basically used as an inverse <see cref="ReaderWriterLockSlim"/>,
12+
/// allowing multiple threads for <see cref="Add"/> or exclusive access for <see cref="FlushScope.Flush"/>.
1113
/// </remarks>
1214
[DebuggerDisplay("Name = {Name}, Capacity = {Capacity}, Additions = {_additions}, AddCount = {AddCount}, IsDisposed = {_disposed}")]
1315
internal sealed class StructuredLogBatchBuffer : IDisposable

src/Sentry/Threading/ScopedCountdownLock.cs

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ internal ScopedCountdownLock()
4040
internal bool IsEngaged => _isEngaged == 1;
4141

4242
/// <summary>
43-
/// No <see cref="CounterScope"/> will be entered when the <see cref="Count"/> has reached <see langword="0"/> while the lock is engaged via an active <see cref="LockScope"/>.
43+
/// No <see cref="CounterScope"/> will be entered when the <see cref="Count"/> has reached <see langword="0"/>, or while the lock is engaged via an active <see cref="LockScope"/>.
4444
/// Check via <see cref="CounterScope.IsEntered"/> whether the underlying <see cref="CountdownEvent"/> has not been set/signaled yet.
4545
/// To signal the underlying <see cref="CountdownEvent"/>, ensure <see cref="CounterScope.Dispose"/> is called.
4646
/// </summary>
@@ -49,6 +49,11 @@ internal ScopedCountdownLock()
4949
/// </remarks>
5050
internal CounterScope TryEnterCounterScope()
5151
{
52+
if (IsEngaged)
53+
{
54+
return new CounterScope();
55+
}
56+
5257
if (_event.TryAddCount(1))
5358
{
5459
return new CounterScope(this);
@@ -59,6 +64,7 @@ internal CounterScope TryEnterCounterScope()
5964

6065
private void ExitCounterScope()
6166
{
67+
Debug.Assert(_event.CurrentCount >= 1);
6268
_ = _event.Signal();
6369
}
6470

@@ -75,7 +81,8 @@ internal LockScope TryEnterLockScope()
7581
{
7682
if (Interlocked.CompareExchange(ref _isEngaged, 1, 0) == 0)
7783
{
78-
_ = _event.Signal(); // decrement the initial count of 1, so that the event can be set with the count reaching 0 when all 'CounterScope's have exited
84+
Debug.Assert(_event.CurrentCount >= 1);
85+
_ = _event.Signal(); // decrement the initial count of 1, so that the event can be set with the count reaching 0 when all entered 'CounterScope' instances have exited
7986
return new LockScope(this);
8087
}
8188

@@ -84,13 +91,13 @@ internal LockScope TryEnterLockScope()
8491

8592
private void ExitLockScope()
8693
{
87-
if (Interlocked.CompareExchange(ref _isEngaged, 0, 1) == 1)
94+
Debug.Assert(_event.IsSet);
95+
_event.Reset(); // reset the signaled event to the initial count of 1, so that new 'CounterScope' instances can be entered again
96+
97+
if (Interlocked.CompareExchange(ref _isEngaged, 0, 1) != 1)
8898
{
89-
_event.Reset(); // reset the signaled event to the initial count of 1, so that new `CounterScope`s can be entered again
90-
return;
99+
Debug.Fail("The Lock should have not been disengaged without being engaged first.");
91100
}
92-
93-
Debug.Fail("The Lock should have not been disengaged without being engaged first.");
94101
}
95102

96103
/// <inheritdoc />
@@ -139,7 +146,7 @@ internal LockScope(ScopedCountdownLock lockObj)
139146
internal void Wait()
140147
{
141148
var lockObj = _lockObj;
142-
lockObj?._event.Wait();
149+
lockObj?._event.Wait(Timeout.Infinite, CancellationToken.None);
143150
}
144151

145152
public void Dispose()

test/Sentry.Tests/Threading/ScopedCountdownLockTests.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,20 +79,20 @@ public void TryEnterLockScope_IsEngaged_IsSet()
7979
lockTwo.Dispose();
8080
AssertEngaged(false, 1);
8181

82-
// successfully enter another CounterScope ... lock is engaged but not yet set
82+
// cannot enter another CounterScope as long as the lock is already engaged by a LockScope
8383
var counterTwo = _lock.TryEnterCounterScope();
84-
counterTwo.IsEntered.Should().BeTrue();
85-
AssertEngaged(false, 2);
84+
counterTwo.IsEntered.Should().BeFalse();
85+
AssertEngaged(false, 1);
8686

87-
// exit a CounterScope ... decrement the count
87+
// no-op ... CounterScope is not entered
8888
counterTwo.Dispose();
8989
AssertEngaged(false, 1);
9090

9191
// exit last CounterScope ... count of engaged lock reaches zero ... sets the lock
9292
counterOne.Dispose();
9393
AssertEngaged(true, 0);
9494

95-
// cannot enter another CounterScope as long as the engaged lock is set
95+
// cannot enter another CounterScope as long as the lock is engaged and set
9696
var counterThree = _lock.TryEnterCounterScope();
9797
counterThree.IsEntered.Should().BeFalse();
9898
AssertEngaged(true, 0);
@@ -102,11 +102,11 @@ public void TryEnterLockScope_IsEngaged_IsSet()
102102
// would block if the count of the engaged lock was not zero
103103
lockOne.Wait();
104104

105-
// exit the LockScope ... reset the lock
105+
// exit the LockScope ... reset and disengage the lock
106106
lockOne.Dispose();
107107
AssertDisengaged(false, 0);
108108

109-
// can enter a CounterScope again ... the lock not set
109+
// can enter a CounterScope again ... the lock is no longer set and no longer engaged
110110
var counterFour = _lock.TryEnterCounterScope();
111111
counterFour.IsEntered.Should().BeTrue();
112112
AssertDisengaged(false, 1);

0 commit comments

Comments
 (0)