fix: cap automatic playout refreshes
This commit is contained in:
@@ -181,11 +181,145 @@ public sealed class LegacyRefreshSchedulerTests
|
||||
Assert.Equal(0, delays.CallCount);
|
||||
}
|
||||
|
||||
private static LegacyRefreshScheduler CreateScheduler(ControlledDelay delays) =>
|
||||
[Fact]
|
||||
public async Task MaximumOne_StopsBeforeASecondDelayOrRefreshCanBeScheduled()
|
||||
{
|
||||
var delays = new ControlledDelay();
|
||||
var scheduler = CreateScheduler(delays, maximumRefreshCount: 1);
|
||||
var untrackedCalls = 0;
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
scheduler.ExecuteRefreshAsync(
|
||||
_ =>
|
||||
{
|
||||
untrackedCalls++;
|
||||
return Task.FromResult(true);
|
||||
},
|
||||
CancellationToken.None));
|
||||
Assert.Equal(0, untrackedCalls);
|
||||
Assert.Throws<InvalidOperationException>(scheduler.MarkRefreshSucceeded);
|
||||
|
||||
var firstOpportunity = scheduler.WaitForNextRefreshAsync(
|
||||
_ => Task.FromResult(true),
|
||||
null,
|
||||
CancellationToken.None);
|
||||
Assert.Equal(TimeSpan.FromSeconds(2), await delays.WaitForCallAsync(0));
|
||||
delays.Complete(0);
|
||||
Assert.True(await firstOpportunity);
|
||||
|
||||
Assert.True(await scheduler.ExecuteRefreshAndTrackSuccessAsync(
|
||||
_ => Task.FromResult(true),
|
||||
static result => result,
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Equal(1, scheduler.CompletedRefreshCount);
|
||||
Assert.Equal(1, scheduler.MaximumRefreshCount);
|
||||
Assert.True(scheduler.HasReachedMaximum);
|
||||
Assert.False(scheduler.IsMaximumCallbackDrained);
|
||||
var finalCallback = NewCompletion<bool>();
|
||||
var drain = scheduler.DrainMaximumCallbackAsync(
|
||||
cancellationToken => finalCallback.Task.WaitAsync(cancellationToken),
|
||||
CancellationToken.None);
|
||||
await Task.Yield();
|
||||
Assert.False(drain.IsCompleted);
|
||||
Assert.False(scheduler.IsMaximumCallbackDrained);
|
||||
finalCallback.SetResult(true);
|
||||
Assert.True(await drain);
|
||||
Assert.True(scheduler.IsMaximumCallbackDrained);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
scheduler.WaitForNextRefreshAsync(
|
||||
_ => Task.FromResult(true),
|
||||
null,
|
||||
CancellationToken.None));
|
||||
var unexpectedRefreshCalls = 0;
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
scheduler.ExecuteRefreshAsync(
|
||||
_ =>
|
||||
{
|
||||
unexpectedRefreshCalls++;
|
||||
return Task.FromResult(true);
|
||||
},
|
||||
CancellationToken.None));
|
||||
Assert.Equal(0, unexpectedRefreshCalls);
|
||||
Assert.Equal(1, delays.CallCount);
|
||||
Assert.Throws<InvalidOperationException>(scheduler.MarkRefreshSucceeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullMaximum_PreservesUnlimitedLegacyScheduling()
|
||||
{
|
||||
var scheduler = CreateScheduler(new ControlledDelay());
|
||||
|
||||
for (var index = 0; index < 10_000; index++)
|
||||
{
|
||||
scheduler.MarkRefreshSucceeded();
|
||||
}
|
||||
|
||||
Assert.Null(scheduler.MaximumRefreshCount);
|
||||
Assert.Equal(10_000, scheduler.CompletedRefreshCount);
|
||||
Assert.False(scheduler.HasReachedMaximum);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroMaximum_DisablesAutomaticRefreshBeforeTheFirstDelay()
|
||||
{
|
||||
var scheduler = CreateScheduler(new ControlledDelay(), maximumRefreshCount: 0);
|
||||
|
||||
Assert.Equal(0, scheduler.CompletedRefreshCount);
|
||||
Assert.Equal(0, scheduler.MaximumRefreshCount);
|
||||
Assert.True(scheduler.HasReachedMaximum);
|
||||
Assert.True(scheduler.IsMaximumCallbackDrained);
|
||||
Assert.Throws<InvalidOperationException>(scheduler.MarkRefreshSucceeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MaximumCallbackTimeout_DoesNotConfirmTheLimit()
|
||||
{
|
||||
var scheduler = CreateScheduler(new ControlledDelay(), maximumRefreshCount: 1);
|
||||
Assert.True(await scheduler.ExecuteRefreshAndTrackSuccessAsync(
|
||||
_ => Task.FromResult(true),
|
||||
static result => result,
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.False(await scheduler.DrainMaximumCallbackAsync(
|
||||
_ => Task.FromResult(false),
|
||||
CancellationToken.None));
|
||||
Assert.True(scheduler.HasReachedMaximum);
|
||||
Assert.False(scheduler.IsMaximumCallbackDrained);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailedTrackedRefresh_DoesNotConsumeTheMaximum()
|
||||
{
|
||||
var scheduler = CreateScheduler(new ControlledDelay(), maximumRefreshCount: 1);
|
||||
|
||||
Assert.False(await scheduler.ExecuteRefreshAndTrackSuccessAsync(
|
||||
_ => Task.FromResult(false),
|
||||
static result => result,
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Equal(0, scheduler.CompletedRefreshCount);
|
||||
Assert.False(scheduler.HasReachedMaximum);
|
||||
Assert.False(scheduler.IsMaximumCallbackDrained);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeMaximum_IsRejected()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new LegacyRefreshScheduler(
|
||||
TimeSpan.FromSeconds(2),
|
||||
TimeSpan.FromSeconds(3),
|
||||
maximumRefreshCount: -1));
|
||||
}
|
||||
|
||||
private static LegacyRefreshScheduler CreateScheduler(
|
||||
ControlledDelay delays,
|
||||
int? maximumRefreshCount = null) =>
|
||||
new(
|
||||
TimeSpan.FromSeconds(2),
|
||||
TimeSpan.FromSeconds(3),
|
||||
delays.DelayAsync);
|
||||
delays.DelayAsync,
|
||||
maximumRefreshCount);
|
||||
|
||||
private static async Task<bool> RunOneRefreshAsync(
|
||||
LegacyRefreshScheduler scheduler,
|
||||
|
||||
@@ -29,6 +29,7 @@ public sealed class PlayoutOptionsLoaderTests
|
||||
"MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS",
|
||||
"MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS",
|
||||
"MBN_STOCK_PLAYOUT_RECONNECT_ENABLED",
|
||||
"MBN_STOCK_PLAYOUT_MAXIMUM_AUTOMATIC_REFRESHES_PER_TAKE_IN",
|
||||
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
|
||||
"MBN_STOCK_PLAYOUT_TEST_SCENE_ALLOWLIST",
|
||||
"MBN_STOCK_PLAYOUT_TRUSTED_LIVE_OUTPUT_ENABLED"
|
||||
@@ -58,6 +59,7 @@ public sealed class PlayoutOptionsLoaderTests
|
||||
Assert.Empty(options.TestSceneAllowlist);
|
||||
Assert.False(options.TrustedLiveOutputEnabled);
|
||||
Assert.True(options.ReconnectEnabled);
|
||||
Assert.Null(options.MaximumAutomaticRefreshesPerTakeIn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -87,6 +89,7 @@ public sealed class PlayoutOptionsLoaderTests
|
||||
.Set("MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS", "1505")
|
||||
.Set("MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS", "4")
|
||||
.Set("MBN_STOCK_PLAYOUT_RECONNECT_ENABLED", "false")
|
||||
.Set("MBN_STOCK_PLAYOUT_MAXIMUM_AUTOMATIC_REFRESHES_PER_TAKE_IN", "999")
|
||||
.Set("MBN_STOCK_PLAYOUT_TEST_SCENE_ALLOWLIST", "C:\\env\\must-not-apply.t2s")
|
||||
.Set("MBN_STOCK_PLAYOUT_TRUSTED_LIVE_OUTPUT_ENABLED", "false");
|
||||
using var file = TemporaryJsonFile.Create(
|
||||
@@ -110,7 +113,8 @@ public sealed class PlayoutOptionsLoaderTests
|
||||
"processPollIntervalMilliseconds": 1000,
|
||||
"reconnectDelayMilliseconds": 1000,
|
||||
"maximumReconnectAttempts": 3,
|
||||
"reconnectEnabled": true
|
||||
"reconnectEnabled": true,
|
||||
"maximumAutomaticRefreshesPerTakeIn": 1
|
||||
}
|
||||
""");
|
||||
|
||||
@@ -139,6 +143,7 @@ public sealed class PlayoutOptionsLoaderTests
|
||||
Assert.Equal(1505, options.ReconnectDelayMilliseconds);
|
||||
Assert.Equal(4, options.MaximumReconnectAttempts);
|
||||
Assert.False(options.ReconnectEnabled);
|
||||
Assert.Equal(1, options.MaximumAutomaticRefreshesPerTakeIn);
|
||||
Assert.Equal(["C:\\test-scenes\\allowed.t2s"], options.TestSceneAllowlist);
|
||||
Assert.True(options.TrustedLiveOutputEnabled);
|
||||
}
|
||||
@@ -177,6 +182,24 @@ public sealed class PlayoutOptionsLoaderTests
|
||||
Assert.DoesNotContain("invalid-json", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_StringAutomaticRefreshMaximumFailsClosed()
|
||||
{
|
||||
using var environment = ClearedEnvironment();
|
||||
using var file = TemporaryJsonFile.Create(
|
||||
"""
|
||||
{
|
||||
"maximumAutomaticRefreshesPerTakeIn": "1"
|
||||
}
|
||||
""");
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => PlayoutOptionsLoader.Load(file.Path));
|
||||
|
||||
Assert.DoesNotContain(file.Path, exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("\"1\"", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MBN_STOCK_PLAYOUT_MODE", "not-a-mode")]
|
||||
[InlineData("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND", "not-a-background")]
|
||||
|
||||
@@ -510,6 +510,35 @@ public sealed class PlayoutSafetyValidationTests : IDisposable
|
||||
Assert.Contains(expectedProperty, exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(1_000_001)]
|
||||
public void Validate_OutOfRangeAutomaticRefreshMaximumIsRejected(int value)
|
||||
{
|
||||
var options = ValidTestOptions();
|
||||
options.MaximumAutomaticRefreshesPerTakeIn = value;
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => ValidatedPlayoutOptions.Create(options));
|
||||
|
||||
Assert.Contains(
|
||||
nameof(PlayoutOptions.MaximumAutomaticRefreshesPerTakeIn),
|
||||
exception.Message,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(1_000_000)]
|
||||
public void Validate_AutomaticRefreshMaximumAcceptsClosedSafeRange(int value)
|
||||
{
|
||||
var options = ValidTestOptions();
|
||||
options.MaximumAutomaticRefreshesPerTakeIn = value;
|
||||
|
||||
_ = ValidatedPlayoutOptions.Create(options);
|
||||
}
|
||||
|
||||
public void Dispose() => _scenes.Dispose();
|
||||
|
||||
private PlayoutOptions ValidTestOptions() => new()
|
||||
|
||||
@@ -158,6 +158,83 @@ test("A database refresh fault clears only after TAKE OUT resets refresh state",
|
||||
assert.equal(safety.shouldClearRefreshError(unrelated, false), false);
|
||||
});
|
||||
|
||||
test("Refresh budget status requires a closed nonnegative maximum and native drain proof", () => {
|
||||
assert.deepEqual(safety.normalizeRefreshBudget({}), {
|
||||
completed: 0,
|
||||
maximum: null,
|
||||
limitReached: false
|
||||
});
|
||||
assert.deepEqual(safety.normalizeRefreshBudget({
|
||||
refreshCompletedCount: 1,
|
||||
refreshMaximumCount: 1,
|
||||
refreshLimitReached: false
|
||||
}), {
|
||||
completed: 1,
|
||||
maximum: 1,
|
||||
limitReached: false
|
||||
});
|
||||
assert.deepEqual(safety.normalizeRefreshBudget({
|
||||
refreshCompletedCount: 1,
|
||||
refreshMaximumCount: 1,
|
||||
refreshLimitReached: true,
|
||||
refreshActive: false,
|
||||
playCompletionPending: false,
|
||||
refreshFaulted: false
|
||||
}), {
|
||||
completed: 1,
|
||||
maximum: 1,
|
||||
limitReached: true
|
||||
});
|
||||
assert.deepEqual(safety.normalizeRefreshBudget({
|
||||
refreshCompletedCount: 2,
|
||||
refreshMaximumCount: -1,
|
||||
refreshLimitReached: true
|
||||
}), {
|
||||
completed: 2,
|
||||
maximum: null,
|
||||
limitReached: false
|
||||
});
|
||||
for (const contradiction of [
|
||||
{ refreshActive: true },
|
||||
{ playCompletionPending: true },
|
||||
{ refreshFaulted: true }
|
||||
]) {
|
||||
assert.equal(safety.normalizeRefreshBudget({
|
||||
refreshCompletedCount: 1,
|
||||
refreshMaximumCount: 1,
|
||||
refreshLimitReached: true,
|
||||
...contradiction
|
||||
}).limitReached, false);
|
||||
}
|
||||
assert.equal(safety.normalizeRefreshBudget({
|
||||
refreshCompletedCount: Number.MAX_SAFE_INTEGER,
|
||||
refreshMaximumCount: 1000001,
|
||||
refreshLimitReached: true
|
||||
}).maximum, null);
|
||||
});
|
||||
|
||||
test("Refresh budget labels distinguish active, callback-drained capped, fault and default states", () => {
|
||||
assert.equal(safety.formatRefreshBudgetState({}), "STOPPED");
|
||||
assert.equal(safety.formatRefreshBudgetState({
|
||||
refreshActive: true,
|
||||
refreshCompletedCount: 0,
|
||||
refreshMaximumCount: 1
|
||||
}, "18:00:00"), "ACTIVE · 0/1 · 18:00:00");
|
||||
assert.equal(safety.formatRefreshBudgetState({
|
||||
refreshCompletedCount: 1,
|
||||
refreshMaximumCount: 1,
|
||||
refreshLimitReached: true,
|
||||
refreshActive: false,
|
||||
playCompletionPending: false,
|
||||
refreshFaulted: false
|
||||
}), "CAPPED · 1/1");
|
||||
assert.equal(safety.formatRefreshBudgetState({
|
||||
refreshFaulted: true,
|
||||
refreshCompletedCount: 1,
|
||||
refreshMaximumCount: 1
|
||||
}), "FAULT");
|
||||
});
|
||||
|
||||
test("Database health snapshots accept only a strictly increasing native sequence", () => {
|
||||
assert.equal(safety.isNewerDatabaseStatusSequence(0, 1), true);
|
||||
assert.equal(safety.isNewerDatabaseStatusSequence(7, 8), true);
|
||||
|
||||
Reference in New Issue
Block a user