1+ // Copyright (c) KeelMatrix
12#nullable enable
3+ using System ;
4+ using System . Collections . Generic ;
5+ using System . Threading ;
6+
27namespace KeelMatrix . QueryWatch {
38 /// <summary>
49 /// Collects query events for the lifetime of a session. Thread‑safe for recording.
10+ /// This version uses a simple <c>lock</c> (monitor) for minimal overhead on write‑heavy workloads,
11+ /// and snapshots the list on <see cref="Stop"/>.
512 /// </summary>
613 public sealed class QueryWatchSession : IDisposable {
714 private readonly List < QueryEvent > _events = new List < QueryEvent > ( ) ;
8- private readonly ReaderWriterLockSlim _gate = new ReaderWriterLockSlim ( ) ;
15+ private readonly object _sync = new object ( ) ;
916 private bool _disposed ;
1017 private int _stopped ; // 0 = running, 1 = stopped
1118
@@ -18,56 +25,34 @@ public QueryWatchSession(QueryWatchOptions? options = null) {
1825 StartedAt = DateTimeOffset . UtcNow ;
1926 }
2027
21- /// <summary>
22- /// Options for this session.
23- /// </summary>
28+ /// <summary>Options for this session.</summary>
2429 public QueryWatchOptions Options { get ; }
2530
26- /// <summary>
27- /// UTC timestamp when the session started.
28- /// </summary>
31+ /// <summary>UTC timestamp when the session started.</summary>
2932 public DateTimeOffset StartedAt { get ; }
3033
31- /// <summary>
32- /// UTC timestamp when the session stopped, or <c>null</c> if still running.
33- /// </summary>
34+ /// <summary>UTC timestamp when the session stopped, or <c>null</c> if still running.</summary>
3435 public DateTimeOffset ? StoppedAt { get ; private set ; }
3536
36- /// <summary>
37- /// Starts a new session.
38- /// </summary>
39- /// <param name="options">Optional session options.</param>
40- /// <returns>The started session.</returns>
37+ /// <summary>Starts a new session.</summary>
4138 public static QueryWatchSession Start ( QueryWatchOptions ? options = null ) => new QueryWatchSession ( options ) ;
4239
43- /// <summary>
44- /// Records a query execution event.
45- /// </summary>
46- /// <param name="commandText">Executed SQL or provider command text.</param>
47- /// <param name="duration">Execution duration.</param>
48- public void Record ( string commandText , TimeSpan duration ) {
49- Record ( commandText , duration , meta : null ) ;
50- }
40+ /// <summary>Records a query execution event.</summary>
41+ public void Record ( string commandText , TimeSpan duration ) => Record ( commandText , duration , meta : null ) ;
5142
52- /// <summary>
53- /// Records a query execution event with optional metadata.
54- /// </summary>
55- /// <param name="commandText">Executed SQL or provider command text.</param>
56- /// <param name="duration">Execution duration.</param>
57- /// <param name="meta">Optional metadata bag for provider‑specific details.</param>
43+ /// <summary>Records a query execution event with optional metadata.</summary>
5844 public void Record ( string commandText , TimeSpan duration , IReadOnlyDictionary < string , object ? > ? meta ) {
5945 if ( _disposed ) throw new ObjectDisposedException ( nameof ( QueryWatchSession ) ) ;
6046
6147 // Fast path: if already stopped, throw as tests expect.
6248 if ( Volatile . Read ( ref _stopped ) != 0 )
6349 throw new InvalidOperationException ( "Session has been stopped; cannot record new events." ) ;
6450
65- _gate . EnterWriteLock ( ) ;
66- try {
51+ lock ( _sync ) {
6752 if ( _stopped != 0 )
6853 throw new InvalidOperationException ( "Session has been stopped; cannot record new events." ) ;
6954
70- // Optionally redact or drop SQL text .
55+ // Early‑out: if CaptureSqlText=false, avoid any redactor passes and store empty string .
7156 string text = string . Empty ;
7257 if ( Options . CaptureSqlText ) {
7358 text = commandText ?? string . Empty ;
@@ -79,15 +64,9 @@ public void Record(string commandText, TimeSpan duration, IReadOnlyDictionary<st
7964 var ev = new QueryEvent ( text , duration , DateTimeOffset . UtcNow , meta ) ;
8065 _events . Add ( ev ) ;
8166 }
82- finally {
83- _gate . ExitWriteLock ( ) ;
84- }
8567 }
8668
87- /// <summary>
88- /// Stops the session and returns a snapshot report.
89- /// </summary>
90- /// <returns>A report representing the recorded data.</returns>
69+ /// <summary>Stops the session and returns a snapshot report.</summary>
9170 public QueryWatchReport Stop ( ) {
9271 if ( _disposed ) throw new ObjectDisposedException ( nameof ( QueryWatchSession ) ) ;
9372
@@ -100,27 +79,22 @@ public QueryWatchReport Stop() {
10079 throw new InvalidOperationException ( "Session has already been stopped." ) ;
10180 }
10281
103- // Snapshot under read lock
104- _gate . EnterReadLock ( ) ;
105- try {
106- return QueryWatchReport . CreateSnapshot ( _events , Options , StartedAt , StoppedAt ?? now ) ;
107- }
108- finally {
109- _gate . ExitReadLock ( ) ;
82+ // Snapshot under the same lock that protects writes to maintain no-post-stop recording guarantee.
83+ List < QueryEvent > snapshot ;
84+ lock ( _sync ) {
85+ snapshot = new List < QueryEvent > ( _events ) ;
11086 }
87+
88+ return QueryWatchReport . CreateSnapshot ( snapshot , Options , StartedAt , StoppedAt ?? now ) ;
11189 }
11290
113- /// <summary>
114- /// Disposes session resources and marks it as stopped.
115- /// </summary>
91+ /// <summary>Disposes session resources and marks it as stopped.</summary>
11692 public void Dispose ( ) {
11793 // Mark stopped and set StoppedAt once if not set.
11894 if ( Interlocked . Exchange ( ref _stopped , 1 ) == 0 ) {
11995 StoppedAt = DateTimeOffset . UtcNow ;
12096 }
121-
12297 _disposed = true ;
123- _gate . Dispose ( ) ;
12498 }
12599 }
126100}
0 commit comments