fix(communication): failback probation is time-boxed and cleared on address change (review 2026-10-21 03/P-2 follow-up)

This commit is contained in:
Joseph Doherty
2026-10-22 08:28:11 -04:00
parent f4d328aecc
commit 4456a04e81
3 changed files with 60 additions and 2 deletions
@@ -58,6 +58,13 @@ public sealed class SitePairChannelProvider : IDisposable
/// </summary>
internal static readonly TimeSpan ChannelDisposalGrace = TimeSpan.FromMinutes(5);
/// <summary>
/// How long a probe-driven failback stays on probation without a confirming real call; a
/// NodeA failure after this window is treated as a fresh failover (backoff restarts at the
/// initial value). Settable per instance for tests.
/// </summary>
internal TimeSpan FailbackProbationWindow { get; set; } = TimeSpan.FromMinutes(5);
private readonly ISitePskProvider _pskProvider;
private readonly CommunicationOptions _options;
private readonly ILogger<SitePairChannelProvider> _logger;
@@ -169,6 +176,7 @@ public sealed class SitePairChannelProvider : IDisposable
// Addresses changed — return to the preferred node and reset failback backoff.
pair.CurrentIsA = pair.ChannelA is not null;
pair.FailbackOnProbation = false;
pair.ResetFailback();
_logger.LogInformation(
"gRPC channel pair for site {SiteId} refreshed (nodeA={HasA}, nodeB={HasB})",
@@ -373,6 +381,7 @@ public sealed class SitePairChannelProvider : IDisposable
// node that answers the probe but not real traffic is retried ever less often.
pair.CurrentIsA = true;
pair.FailbackOnProbation = true;
pair.ProbationSinceUtc = DateTime.UtcNow;
pair.NextProbeUtc = DateTime.MaxValue;
_logger.LogInformation("Site {SiteId} preferred node A ready again; failing back", siteId);
return true;
@@ -535,7 +544,12 @@ public sealed class SitePairChannelProvider : IDisposable
pair.CurrentIsA = toIsA;
if (!toIsA)
{
if (pair.FailbackOnProbation)
// Probation is time-boxed: a site quiet for longer than FailbackProbationWindow
// after a probe-driven failback (no real call to confirm it) is treated as
// confirmed, so a fresh NodeA failure much later starts at InitialBackoff again
// instead of inheriting a stale, grown backoff.
if (pair.FailbackOnProbation
&& DateTime.UtcNow - pair.ProbationSinceUtc < FailbackProbationWindow)
{
// A failed us again right after a probe-driven failback: it is not really
// ready yet — grow the backoff rather than restarting it.
@@ -545,6 +559,7 @@ public sealed class SitePairChannelProvider : IDisposable
else
{
// Failed over off the preferred node → schedule the first failback probe.
pair.FailbackOnProbation = false;
pair.ScheduleFailback(InitialBackoff);
}
}
@@ -660,6 +675,9 @@ public sealed class SitePairChannelProvider : IDisposable
/// </summary>
public bool FailbackOnProbation;
/// <summary>When the current probation began (meaningful only while <see cref="FailbackOnProbation"/>).</summary>
public DateTime ProbationSinceUtc;
/// <summary>The current failback backoff.</summary>
public TimeSpan Backoff => _backoff;