11using System ;
2+ using System . Collections . Concurrent ;
3+ using System . Linq ;
24using System . Threading ;
35using System . Threading . Tasks ;
46using Microsoft . Extensions . Logging ;
7+ using SharpHoundCommonLib . Exceptions ;
58using SharpHoundRPC . NetAPINative ;
69
710namespace SharpHoundCommonLib ;
811
912public sealed class AdaptiveTimeout : IDisposable {
1013 private readonly ExecutionTimeSampler _sampler ;
14+ private readonly ConcurrentQueue < DateTime > _latestSuccessTimestamps ;
1115 private readonly ILogger _log ;
1216 private readonly TimeSpan _maxTimeout ;
1317 private readonly bool _useAdaptiveTimeout ;
1418 private readonly int _minSamplesForAdaptiveTimeout ;
15- private int _clearSamplesDecay ;
19+ private readonly bool _throwIfExcessiveTimeouts ;
20+ private int _timeSpikeDecay ;
1621 private const int TimeSpikePenalty = 2 ;
1722 private const int TimeSpikeForgiveness = 1 ;
18- private const int ClearSamplesThreshold = 5 ;
23+ private const int TimeSpikeThreshold = 5 ;
24+ private const int ExcessiveTimeoutsThreshold = 7 ;
1925 private const int StdDevMultiplier = 5 ;
26+ private const int CountOfLatestSuccessToKeep = 4 ;
2027
21- public AdaptiveTimeout ( TimeSpan maxTimeout , ILogger log , int sampleCount = 100 , int logFrequency = 1000 , int minSamplesForAdaptiveTimeout = 30 , bool useAdaptiveTimeout = true ) {
28+ public AdaptiveTimeout ( TimeSpan maxTimeout , ILogger log , int sampleCount = 100 , int logFrequency = 1000 , int minSamplesForAdaptiveTimeout = 30 , bool useAdaptiveTimeout = true , bool throwIfExcessiveTimeouts = false ) {
2229 if ( maxTimeout <= TimeSpan . Zero )
2330 throw new ArgumentException ( "maxTimeout must be positive" , nameof ( maxTimeout ) ) ;
2431 if ( sampleCount <= 0 )
@@ -31,14 +38,16 @@ public AdaptiveTimeout(TimeSpan maxTimeout, ILogger log, int sampleCount = 100,
3138 throw new ArgumentNullException ( nameof ( log ) ) ;
3239
3340 _sampler = new ExecutionTimeSampler ( log , sampleCount , logFrequency ) ;
41+ _latestSuccessTimestamps = new ConcurrentQueue < DateTime > ( ) ;
3442 _log = log ;
3543 _maxTimeout = maxTimeout ;
36- _useAdaptiveTimeout = useAdaptiveTimeout ;
3744 _minSamplesForAdaptiveTimeout = minSamplesForAdaptiveTimeout ;
45+ _useAdaptiveTimeout = useAdaptiveTimeout ;
46+ _throwIfExcessiveTimeouts = throwIfExcessiveTimeouts ;
3847 }
3948
4049 public void ClearSamples ( ) {
41- Interlocked . Exchange ( ref _clearSamplesDecay , 0 ) ;
50+ Interlocked . Exchange ( ref _timeSpikeDecay , 0 ) ;
4251 _sampler . ClearSamples ( ) ;
4352 }
4453
@@ -55,8 +64,13 @@ public void ClearSamples() {
5564 /// <param name="parentToken"></param>
5665 /// <returns>Returns a Fail result if a task runs longer than its budgeted time.</returns>
5766 public async Task < Result < T > > ExecuteWithTimeout < T > ( Func < CancellationToken , T > func , CancellationToken parentToken = default ) {
58- var result = await Timeout . ExecuteWithTimeout ( GetAdaptiveTimeout ( ) , ( timeoutToken ) => _sampler . SampleExecutionTime ( ( ) => func ( timeoutToken ) ) , parentToken ) ;
59- TimeSpikeSafetyValve ( result . IsSuccess ) ;
67+ DateTime startTime = default ;
68+ var result = await Timeout . ExecuteWithTimeout ( GetAdaptiveTimeout ( ) , ( timeoutToken ) =>
69+ _sampler . SampleExecutionTime ( ( ) => {
70+ startTime = DateTime . Now ; // for ordinal tracking; see use in TimeSpikeSafetyValve
71+ return func ( timeoutToken ) ;
72+ } ) , parentToken ) ;
73+ TimeSpikeSafetyValve ( result . IsSuccess , startTime ) ;
6074 return result ;
6175 }
6276
@@ -72,8 +86,13 @@ public async Task<Result<T>> ExecuteWithTimeout<T>(Func<CancellationToken, T> fu
7286 /// <param name="parentToken"></param>
7387 /// <returns>Returns a Fail result if a task runs longer than its budgeted time.</returns>
7488 public async Task < Result > ExecuteWithTimeout ( Action < CancellationToken > func , CancellationToken parentToken = default ) {
75- var result = await Timeout . ExecuteWithTimeout ( GetAdaptiveTimeout ( ) , ( timeoutToken ) => _sampler . SampleExecutionTime ( ( ) => func ( timeoutToken ) ) , parentToken ) ;
76- TimeSpikeSafetyValve ( result . IsSuccess ) ;
89+ DateTime startTime = default ;
90+ var result = await Timeout . ExecuteWithTimeout ( GetAdaptiveTimeout ( ) , ( timeoutToken ) =>
91+ _sampler . SampleExecutionTime ( ( ) => {
92+ startTime = DateTime . Now ; // for ordinal tracking; see use in TimeSpikeSafetyValve
93+ func ( timeoutToken ) ;
94+ } ) , parentToken ) ;
95+ TimeSpikeSafetyValve ( result . IsSuccess , startTime ) ;
7796 return result ;
7897 }
7998
@@ -90,8 +109,13 @@ public async Task<Result> ExecuteWithTimeout(Action<CancellationToken> func, Can
90109 /// <param name="parentToken"></param>
91110 /// <returns>Returns a Fail result if a task runs longer than its budgeted time.</returns>
92111 public async Task < Result < T > > ExecuteWithTimeout < T > ( Func < CancellationToken , Task < T > > func , CancellationToken parentToken = default ) {
93- var result = await Timeout . ExecuteWithTimeout ( GetAdaptiveTimeout ( ) , ( timeoutToken ) => _sampler . SampleExecutionTime ( ( ) => func ( timeoutToken ) ) , parentToken ) ;
94- TimeSpikeSafetyValve ( result . IsSuccess ) ;
112+ DateTime startTime = default ;
113+ var result = await Timeout . ExecuteWithTimeout ( GetAdaptiveTimeout ( ) , ( timeoutToken ) =>
114+ _sampler . SampleExecutionTime ( ( ) => {
115+ startTime = DateTime . Now ; // for ordinal tracking; see use in TimeSpikeSafetyValve
116+ return func ( timeoutToken ) ;
117+ } ) , parentToken ) ;
118+ TimeSpikeSafetyValve ( result . IsSuccess , startTime ) ;
95119 return result ;
96120 }
97121
@@ -107,8 +131,13 @@ public async Task<Result<T>> ExecuteWithTimeout<T>(Func<CancellationToken, Task<
107131 /// <param name="parentToken"></param>
108132 /// <returns>Returns a Fail result if a task runs longer than its budgeted time.</returns>
109133 public async Task < Result > ExecuteWithTimeout ( Func < CancellationToken , Task > func , CancellationToken parentToken = default ) {
110- var result = await Timeout . ExecuteWithTimeout ( GetAdaptiveTimeout ( ) , ( timeoutToken ) => _sampler . SampleExecutionTime ( ( ) => func ( timeoutToken ) ) , parentToken ) ;
111- TimeSpikeSafetyValve ( result . IsSuccess ) ;
134+ DateTime startTime = default ;
135+ var result = await Timeout . ExecuteWithTimeout ( GetAdaptiveTimeout ( ) , ( timeoutToken ) =>
136+ _sampler . SampleExecutionTime ( ( ) => {
137+ startTime = DateTime . Now ; // for ordinal tracking; see use in TimeSpikeSafetyValve
138+ return func ( timeoutToken ) ;
139+ } ) , parentToken ) ;
140+ TimeSpikeSafetyValve ( result . IsSuccess , startTime ) ;
112141 return result ;
113142 }
114143
@@ -125,8 +154,13 @@ public async Task<Result> ExecuteWithTimeout(Func<CancellationToken, Task> func,
125154 /// <param name="parentToken"></param>
126155 /// <returns>Returns a Fail result if a task runs longer than its budgeted time.</returns>
127156 public async Task < NetAPIResult < T > > ExecuteNetAPIWithTimeout < T > ( Func < CancellationToken , NetAPIResult < T > > func , CancellationToken parentToken = default ) {
128- var result = await Timeout . ExecuteNetAPIWithTimeout ( GetAdaptiveTimeout ( ) , ( timeoutToken ) => _sampler . SampleExecutionTime ( ( ) => func ( timeoutToken ) ) , parentToken ) ;
129- TimeSpikeSafetyValve ( result . IsSuccess ) ;
157+ DateTime startTime = default ;
158+ var result = await Timeout . ExecuteNetAPIWithTimeout ( GetAdaptiveTimeout ( ) , ( timeoutToken ) =>
159+ _sampler . SampleExecutionTime ( ( ) => {
160+ startTime = DateTime . Now ; // for ordinal tracking; see use in TimeSpikeSafetyValve
161+ return func ( timeoutToken ) ;
162+ } ) , parentToken ) ;
163+ TimeSpikeSafetyValve ( result . IsSuccess , startTime ) ;
130164 return result ;
131165 }
132166
@@ -143,8 +177,13 @@ public async Task<NetAPIResult<T>> ExecuteNetAPIWithTimeout<T>(Func<Cancellation
143177 /// <param name="parentToken"></param>
144178 /// <returns>Returns a Fail result if a task runs longer than its budgeted time.</returns>
145179 public async Task < SharpHoundRPC . Result < T > > ExecuteRPCWithTimeout < T > ( Func < CancellationToken , SharpHoundRPC . Result < T > > func , CancellationToken parentToken = default ) {
146- var result = await Timeout . ExecuteRPCWithTimeout ( GetAdaptiveTimeout ( ) , ( timeoutToken ) => _sampler . SampleExecutionTime ( ( ) => func ( timeoutToken ) ) , parentToken ) ;
147- TimeSpikeSafetyValve ( result . IsSuccess ) ;
180+ DateTime startTime = default ;
181+ var result = await Timeout . ExecuteRPCWithTimeout ( GetAdaptiveTimeout ( ) , ( timeoutToken ) =>
182+ _sampler . SampleExecutionTime ( ( ) => {
183+ startTime = DateTime . Now ; // for ordinal tracking; see use in TimeSpikeSafetyValve
184+ return func ( timeoutToken ) ;
185+ } ) , parentToken ) ;
186+ TimeSpikeSafetyValve ( result . IsSuccess , startTime ) ;
148187 return result ;
149188 }
150189
@@ -161,8 +200,13 @@ public async Task<NetAPIResult<T>> ExecuteNetAPIWithTimeout<T>(Func<Cancellation
161200 /// <param name="parentToken"></param>
162201 /// <returns>Returns a Fail result if a task runs longer than its budgeted time.</returns>
163202 public async Task < SharpHoundRPC . Result < T > > ExecuteRPCWithTimeout < T > ( Func < CancellationToken , Task < SharpHoundRPC . Result < T > > > func , CancellationToken parentToken = default ) {
164- var result = await Timeout . ExecuteRPCWithTimeout ( GetAdaptiveTimeout ( ) , ( timeoutToken ) => _sampler . SampleExecutionTime ( ( ) => func ( timeoutToken ) ) , parentToken ) ;
165- TimeSpikeSafetyValve ( result . IsSuccess ) ;
203+ DateTime startTime = default ;
204+ var result = await Timeout . ExecuteRPCWithTimeout ( GetAdaptiveTimeout ( ) , ( timeoutToken ) =>
205+ _sampler . SampleExecutionTime ( ( ) => {
206+ startTime = DateTime . Now ; // for ordinal tracking; see use in TimeSpikeSafetyValve
207+ return func ( timeoutToken ) ;
208+ } ) , parentToken ) ;
209+ TimeSpikeSafetyValve ( result . IsSuccess , startTime ) ;
166210 return result ;
167211 }
168212
@@ -200,30 +244,66 @@ public TimeSpan GetAdaptiveTimeout() {
200244 // then suddenly starts taking a regular 100ms
201245 // this is fine (if it fits in our max timeout budget), and we shouldn't timeout
202246 // so we should create a safety valve in case this happens to reset our data samples
203- private void TimeSpikeSafetyValve ( bool isSuccess ) {
247+ private void TimeSpikeSafetyValve ( bool isSuccess , DateTime startTime ) {
204248 if ( isSuccess ) {
205- AtomicDecrementWithFloor ( ref _clearSamplesDecay , TimeSpikeForgiveness ) ;
249+ AtomicDecrementWithFloor ( ref _timeSpikeDecay , TimeSpikeForgiveness ) ;
250+ AddLatestSuccessTimestamp ( startTime ) ;
206251 }
207252 else {
208- Interlocked . Add ( ref _clearSamplesDecay , TimeSpikePenalty ) ;
253+ Interlocked . Add ( ref _timeSpikeDecay , TimeSpikePenalty ) ;
209254
210- if ( _clearSamplesDecay >= ClearSamplesThreshold ) {
211- if ( UseAdaptiveTimeout ( ) ) {
212- ClearSamples ( ) ;
213- _log . LogTrace ( "Time spike safety valve event at timeout {CurrentTimeout}." , GetAdaptiveTimeout ( ) ) ;
255+ if ( Volatile . Read ( ref _timeSpikeDecay ) >= TimeSpikeThreshold ) {
256+ if ( EnoughSuccessesSince ( startTime ) ) {
257+ // Time spike is in the past now, no action needed
258+ // This happens when earlier calls report back timeouts
259+ // but we've since seen sufficent successful calls completed in the time between
260+ _log . LogTrace ( "Time spike hiccup spotted but since recovered." ) ;
261+ Interlocked . Exchange ( ref _timeSpikeDecay , 0 ) ;
214262 }
215263 else {
216- _log . LogWarning ( "This call is frequently running over the maximum allowed timeout of {MaxTimeout}." , _maxTimeout ) ;
217- Interlocked . Exchange ( ref _clearSamplesDecay , 0 ) ;
264+ TriggerTimeSpikeEvent ( ) ;
218265 }
219266 }
220267 }
221268 }
222269
270+ private void TriggerTimeSpikeEvent ( ) {
271+ // Most recent calls made have been timing out
272+ // If adaptive timeout is in play when a spike in timeout events occurs,
273+ // flush our samples and back off to the max timeout until we have enough new ones
274+ // to rebuild our data confidence
275+ if ( UseAdaptiveTimeout ( ) ) {
276+ _log . LogTrace ( "Time spike safety valve event at timeout {CurrentTimeout}." , GetAdaptiveTimeout ( ) ) ;
277+ ClearSamples ( ) ;
278+ }
279+
280+ // Otherwise, if we're using the max configured timeout already and this spike in timeout events is still occuring,
281+ // log it and maybe throw an error if so configuredx
282+ else if ( Volatile . Read ( ref _timeSpikeDecay ) >= ExcessiveTimeoutsThreshold ) {
283+ _log . LogWarning ( "This call is frequently running over the maximum allowed timeout of {MaxTimeout}." , _maxTimeout ) ;
284+ Interlocked . Exchange ( ref _timeSpikeDecay , 0 ) ;
285+
286+ if ( _throwIfExcessiveTimeouts )
287+ throw new ExcessiveTimeoutsException ( $ "This call is frequently running over the maximum allowed timeout of { _maxTimeout } .") ;
288+ }
289+ }
290+
223291 private bool UseAdaptiveTimeout ( ) {
224292 return _useAdaptiveTimeout && _sampler . Count >= _minSamplesForAdaptiveTimeout ;
225293 }
226294
295+ private void AddLatestSuccessTimestamp ( DateTime startTime ) {
296+ while ( _latestSuccessTimestamps . Count >= CountOfLatestSuccessToKeep ) {
297+ _latestSuccessTimestamps . TryDequeue ( out var _ ) ;
298+ }
299+
300+ _latestSuccessTimestamps . Enqueue ( startTime ) ;
301+ }
302+
303+ private bool EnoughSuccessesSince ( DateTime startTime ) {
304+ return _latestSuccessTimestamps . All ( t => t >= startTime ) ;
305+ }
306+
227307 // AI-generated code
228308 // Effectively accomplishes:
229309 // // Interlocked.Add(ref location, -decrement);
0 commit comments