TwitchDownloader/dotnet/src/TwitchArchive.Core/Recovery/RecoveryPolicy.cs

131 lines
5.4 KiB
C#

using System;
namespace TwitchArchive.Core.Recovery
{
public enum RecoveryState
{
Monitoring,
Recording,
FastReconnect,
SlowReconnect,
NetworkFault
}
public enum RecoveryAction
{
None,
StartRecording,
StartProcessing,
SleepOnly
}
public sealed class RecoveryDecision
{
public RecoveryAction Action { get; init; }
public TimeSpan Sleep { get; init; }
public static RecoveryDecision SleepFor(TimeSpan t) => new RecoveryDecision { Action = RecoveryAction.SleepOnly, Sleep = t };
public static RecoveryDecision StartRecordingNow() => new RecoveryDecision { Action = RecoveryAction.StartRecording, Sleep = TimeSpan.Zero };
public static RecoveryDecision StartProcessingAndSleep(TimeSpan sleep) => new RecoveryDecision { Action = RecoveryAction.StartProcessing, Sleep = sleep };
public static RecoveryDecision None() => new RecoveryDecision { Action = RecoveryAction.None, Sleep = TimeSpan.Zero };
}
/// <summary>
/// Pure, testable recovery policy state machine.
/// It decides whether to start recording immediately, wait a short period (fast reconnect),
/// wait longer (slow reconnect), or enter a network fault backoff.
///</summary>
public class RecoveryPolicy
{
private RecoveryState _state = RecoveryState.Monitoring;
private readonly TimeSpan _refreshInterval;
private DateTime? _fastReconnectStartUtc;
private int _networkFaultAttempts;
// constants matching requested behavior
private static readonly TimeSpan FastReconnectInterval = TimeSpan.FromSeconds(10);
private static readonly TimeSpan FastReconnectWindow = TimeSpan.FromMinutes(2);
private static readonly TimeSpan SlowReconnectInterval = TimeSpan.FromSeconds(60);
private static readonly TimeSpan NetworkFaultBase = TimeSpan.FromSeconds(30);
private static readonly TimeSpan NetworkFaultMax = TimeSpan.FromMinutes(10);
public RecoveryPolicy(TimeSpan? refreshInterval = null)
{
_refreshInterval = refreshInterval ?? TimeSpan.FromSeconds(60);
}
/// <summary>
/// Evaluate the policy given current time, whether the streamer is live (null = unknown),
/// and whether there was a network error reaching Twitch APIs.
/// Returns a RecoveryDecision describing the next action and how long to wait if sleeping.
/// </summary>
public RecoveryDecision Tick(DateTime nowUtc, bool? isLive, bool networkError)
{
// Network error handling: enter NetworkFault state with exponential backoff
if (networkError)
{
_networkFaultAttempts++;
_state = RecoveryState.NetworkFault;
var backoff = NetworkFaultBase * Math.Pow(2, Math.Max(0, _networkFaultAttempts - 1));
if (backoff > NetworkFaultMax) backoff = NetworkFaultMax;
return RecoveryDecision.SleepFor(backoff);
}
// If we recover from network fault, reset attempts
if (_state == RecoveryState.NetworkFault && !networkError)
{
_networkFaultAttempts = 0;
_state = RecoveryState.Monitoring;
}
// If live -> start recording immediately
if (isLive == true)
{
_state = RecoveryState.Recording;
_fastReconnectStartUtc = null;
return RecoveryDecision.StartRecordingNow();
}
// If unknown live status, treat conservatively: sleep a short amount (refresh)
if (isLive == null)
{
return RecoveryDecision.SleepFor(_refreshInterval);
}
// isLive == false here
switch (_state)
{
case RecoveryState.Recording:
// Just transitioned from recording to not-live: start fast reconnect window
_state = RecoveryState.FastReconnect;
_fastReconnectStartUtc = nowUtc;
return RecoveryDecision.SleepFor(FastReconnectInterval);
case RecoveryState.FastReconnect:
if (_fastReconnectStartUtc.HasValue && (nowUtc - _fastReconnectStartUtc.Value) < FastReconnectWindow)
{
// stay in fast reconnect, poll frequently
return RecoveryDecision.SleepFor(FastReconnectInterval);
}
else
{
// fast reconnect window expired -> move to slow reconnect and trigger processing
_state = RecoveryState.SlowReconnect;
_fastReconnectStartUtc = null;
return RecoveryDecision.StartProcessingAndSleep(SlowReconnectInterval);
}
case RecoveryState.SlowReconnect:
// In slow reconnect, poll less frequently
return RecoveryDecision.SleepFor(SlowReconnectInterval);
case RecoveryState.Monitoring:
default:
// Normal monitoring cadence
return RecoveryDecision.SleepFor(_refreshInterval);
}
}
public RecoveryState CurrentState => _state;
}
}