fix: cap automatic playout refreshes

This commit is contained in:
2026-07-12 19:28:15 +09:00
parent 6e00955f1b
commit df60e09884
16 changed files with 603 additions and 37 deletions

View File

@@ -39,6 +39,7 @@ public sealed partial class MainWindow
_legacyCompositionOptionsSource =
new MutableLegacySceneCueCompositionOptionsSource(compositionOptions);
_playoutEngine = PlayoutEngineFactory.Create(options);
ResetLegacyRefreshStatus();
_playoutEngine.StatusChanged += OnPlayoutStatusChanged;
ObserveNativeTornadoState(_playoutEngine.Status.State);
if (_databaseRuntime is not null)
@@ -316,7 +317,10 @@ public sealed partial class MainWindow
refreshLastSuccessAt = refreshStatus.LastSuccessAtUtc,
refreshFaulted = refreshStatus.IsFaulted,
refreshFaultCode = refreshStatus.FaultCode,
refreshFaultMessage = refreshStatus.FaultMessage
refreshFaultMessage = refreshStatus.FaultMessage,
refreshCompletedCount = refreshStatus.CompletedRefreshCount,
refreshMaximumCount = refreshStatus.MaximumRefreshCount,
refreshLimitReached = refreshStatus.IsLimitReached
});
}
catch (OperationCanceledException)
@@ -499,6 +503,9 @@ public sealed partial class MainWindow
refreshFaulted = false,
refreshFaultCode = (string?)null,
refreshFaultMessage = (string?)null,
refreshCompletedCount = 0,
refreshMaximumCount = (int?)null,
refreshLimitReached = false,
changedAt
});
}
@@ -546,6 +553,9 @@ public sealed partial class MainWindow
refreshFaulted = false,
refreshFaultCode = (string?)null,
refreshFaultMessage = (string?)null,
refreshCompletedCount = 0,
refreshMaximumCount = (int?)null,
refreshLimitReached = false,
changedAt
});
}
@@ -606,6 +616,9 @@ public sealed partial class MainWindow
refreshFaulted = refreshStatus.IsFaulted,
refreshFaultCode = refreshStatus.FaultCode,
refreshFaultMessage = refreshStatus.FaultMessage,
refreshCompletedCount = refreshStatus.CompletedRefreshCount,
refreshMaximumCount = refreshStatus.MaximumRefreshCount,
refreshLimitReached = refreshStatus.IsLimitReached,
changedAt = status.ChangedAtUtc
});
}
@@ -661,6 +674,9 @@ public sealed partial class MainWindow
refreshFaulted = refreshStatus.IsFaulted,
refreshFaultCode = refreshStatus.FaultCode,
refreshFaultMessage = refreshStatus.FaultMessage,
refreshCompletedCount = refreshStatus.CompletedRefreshCount,
refreshMaximumCount = refreshStatus.MaximumRefreshCount,
refreshLimitReached = refreshStatus.IsLimitReached,
changedAt = status.ChangedAtUtc
};
}
@@ -780,6 +796,7 @@ public sealed partial class MainWindow
private void StartLegacyRefreshLoop(LegacyPlayoutWorkflow workflow)
{
StopLegacyRefreshLoop();
ResetLegacyRefreshStatus();
var cutCode = workflow.State.OnAirCutCode;
if (!LegacySceneRefreshPolicy.TryGetInterval(cutCode, out var interval) ||
interval <= TimeSpan.Zero ||
@@ -792,16 +809,38 @@ public sealed partial class MainWindow
_lifetimeCancellation.Token);
var scheduler = new LegacyRefreshScheduler(
interval,
LegacySceneRefreshPolicy.RecurringInterval);
_legacyRefreshEpoch.Replace(
cancellation,
status => new LegacyRefreshRuntimeStatus(
true,
null,
status.LastSuccessAtUtc,
LegacySceneRefreshPolicy.RecurringInterval,
_playoutOptions?.MaximumAutomaticRefreshesPerTakeIn);
if (scheduler.HasReachedMaximum)
{
cancellation.Dispose();
_legacyRefreshTask = null;
_legacyRefreshEpoch.Stop(_ => new LegacyRefreshRuntimeStatus(
false,
null,
null));
null,
false,
null,
null,
scheduler.CompletedRefreshCount,
scheduler.MaximumRefreshCount,
scheduler.IsMaximumCallbackDrained));
QueuePlayoutStatus();
return;
}
_legacyRefreshEpoch.Replace(
cancellation,
_ => new LegacyRefreshRuntimeStatus(
true,
null,
null,
false,
null,
null,
scheduler.CompletedRefreshCount,
scheduler.MaximumRefreshCount,
false));
_legacyRefreshTask = RunLegacyRefreshLoopAsync(
workflow,
scheduler,
@@ -836,6 +875,36 @@ public sealed partial class MainWindow
return;
}
if (scheduler.HasReachedMaximum)
{
if (!await scheduler.DrainMaximumCallbackAsync(
cancellationToken => WaitForPlayCompletionAsync(
engine,
cancellationToken),
token))
{
TrySetLegacyRefreshFault(
cancellation,
"PLAY_CALLBACK_TIMEOUT",
"The configured automatic refresh limit was reached, but the final Tornado play callback was not observed in time. Verify PGM output before TAKE OUT.");
QueuePlayoutStatus();
return;
}
_legacyRefreshEpoch.TryUpdate(
cancellation,
status => status with
{
IsActive = false,
NextAtUtc = null,
CompletedRefreshCount = scheduler.CompletedRefreshCount,
MaximumRefreshCount = scheduler.MaximumRefreshCount,
IsLimitReached = scheduler.IsMaximumCallbackDrained
});
QueuePlayoutStatus();
return;
}
// The original WinForms timer coalesced non-reentrant UI ticks but did
// not wait for the vendor callback. The migration's safety invariant is
// stricter: observe OnScenePlayed and then start a complete one-shot
@@ -891,8 +960,9 @@ public sealed partial class MainWindow
CancelActiveDatabaseHealthCheck();
await _databaseActivityGate.WaitAsync(token);
databaseActivityEntered = true;
var result = await scheduler.ExecuteRefreshAsync(
var result = await scheduler.ExecuteRefreshAndTrackSuccessAsync(
workflow.RefreshOnAirAsync,
static result => result.IsSuccess,
token);
// A failed, timed-out, or unknown update never repeats. The operator
// must reconcile native/PGM state before starting another command.
@@ -908,7 +978,6 @@ public sealed partial class MainWindow
// MainForm changes timer1.Interval to 3000 after the first
// successful same-scene refresh. The next interval does not
// begin until this Play's completion callback is observed.
scheduler.MarkRefreshSucceeded();
if (!_legacyRefreshEpoch.TryUpdate(
cancellation,
_ => new LegacyRefreshRuntimeStatus(
@@ -917,7 +986,10 @@ public sealed partial class MainWindow
DateTimeOffset.UtcNow,
false,
null,
null)))
null,
scheduler.CompletedRefreshCount,
scheduler.MaximumRefreshCount,
false)))
{
return;
}
@@ -1056,10 +1128,16 @@ public sealed partial class MainWindow
status.LastSuccessAtUtc,
true,
code,
message));
message,
status.CompletedRefreshCount,
status.MaximumRefreshCount,
status.IsLimitReached));
private void ResetLegacyRefreshStatus() =>
_legacyRefreshEpoch.Stop(_ => LegacyRefreshRuntimeStatus.Empty);
_legacyRefreshEpoch.Stop(_ => LegacyRefreshRuntimeStatus.Empty with
{
MaximumRefreshCount = _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn
});
/// <summary>
/// Provides the single fail-closed playout boundary used by every operator-data
@@ -1158,7 +1236,10 @@ public sealed partial class MainWindow
DateTimeOffset? LastSuccessAtUtc,
bool IsFaulted,
string? FaultCode,
string? FaultMessage)
string? FaultMessage,
int CompletedRefreshCount,
int? MaximumRefreshCount,
bool IsLimitReached)
{
public static LegacyRefreshRuntimeStatus Empty { get; } = new(
false,
@@ -1166,6 +1247,9 @@ public sealed partial class MainWindow
null,
false,
null,
null);
null,
0,
null,
false);
}
}