fix: preserve prepared workflow state

This commit is contained in:
2026-07-11 12:45:03 +09:00
parent d11abfa567
commit 8864440ac7
2 changed files with 112 additions and 11 deletions

View File

@@ -74,10 +74,12 @@ public sealed class LegacyPlayoutWorkflow
private readonly IPlayoutEngine _engine;
private readonly ILegacySceneCueProvider _cueProvider;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly object _engineStatusSync = new();
private IReadOnlyList<LegacyPlaylistEntry> _playlist = [];
private ActivePage? _prepared;
private ActivePage? _onAir;
private int _engineClearRequested;
private long _lastObservedEngineStatusSequence = long.MinValue;
public LegacyPlayoutWorkflow(
IPlayoutEngine engine,
@@ -98,13 +100,27 @@ public sealed class LegacyPlayoutWorkflow
public void ObserveEngineStatus(PlayoutStatus status)
{
ArgumentNullException.ThrowIfNull(status);
if (!string.IsNullOrWhiteSpace(status.PreparedSceneName) ||
!string.IsNullOrWhiteSpace(status.OnAirSceneName))
var hasActiveScene = !string.IsNullOrWhiteSpace(status.PreparedSceneName) ||
!string.IsNullOrWhiteSpace(status.OnAirSceneName);
lock (_engineStatusSync)
{
if (status.Sequence <= _lastObservedEngineStatusSequence)
{
return;
}
_lastObservedEngineStatusSequence = status.Sequence;
// Availability/OnHello updates can report no scene while PREPARE still owns
// the workflow gate. A later authoritative PREPARED/PROGRAM status must
// cancel that deferred clear before the command publishes its page metadata.
Interlocked.Exchange(ref _engineClearRequested, hasActiveScene ? 0 : 1);
}
if (hasActiveScene)
{
return;
}
Interlocked.Exchange(ref _engineClearRequested, 1);
if (!_gate.Wait(0))
{
return;
@@ -538,15 +554,18 @@ public sealed class LegacyPlayoutWorkflow
private void ApplyRequestedEngineClear()
{
if (Interlocked.Exchange(ref _engineClearRequested, 0) == 0)
lock (_engineStatusSync)
{
return;
}
if (Interlocked.Exchange(ref _engineClearRequested, 0) == 0)
{
return;
}
_prepared = null;
_onAir = null;
_playlist = [];
State = EmptyState;
_prepared = null;
_onAir = null;
_playlist = [];
State = EmptyState;
}
}
private static readonly LegacyPlayoutWorkflowState EmptyState = new(

View File

@@ -352,6 +352,79 @@ public sealed class LegacyPlayoutWorkflowTests
Assert.DoesNotContain(engine.Calls, call => call.StartsWith("UpdateOnAir", StringComparison.Ordinal));
}
[Fact]
public async Task Prepare_EmptyThenPreparedEngineStatusWhileGateIsHeld_PreservesPublishedPageState()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5074"] = (6, ScenePageSize.Five)
})
{
BuilderKey = "kospi-five-row",
PreviewFields = [new("text", "title", "KOSPI")]
};
var workflow = new LegacyPlayoutWorkflow(engine, provider);
engine.PrepareCallback = () =>
{
workflow.ObserveEngineStatus(engine.Status with
{
PreparedSceneName = null,
OnAirSceneName = null,
Sequence = 10
});
workflow.ObserveEngineStatus(engine.Status with
{
PreparedSceneName = "5074",
OnAirSceneName = null,
Sequence = 11
});
};
var result = await workflow.PrepareAsync([new("playlist-entry", "5074")], 0);
Assert.True(result.IsSuccess);
Assert.Equal("5074", workflow.State.PreparedCutCode);
Assert.Null(workflow.State.OnAirCutCode);
Assert.Equal("kospi-five-row", workflow.State.BuilderKey);
Assert.Equal(0, workflow.State.PageIndexZeroBased);
Assert.Equal(2, workflow.State.PageCount);
Assert.Equal(5, workflow.State.PageSize);
Assert.Equal(6, workflow.State.ItemCount);
Assert.Equal(5, workflow.State.CurrentPageItemCount);
Assert.Equal("playlist-entry", workflow.State.CurrentEntryId);
Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);
Assert.Equal(provider.PreviewFields, workflow.State.PreviewFields);
}
[Fact]
public async Task ObserveEngineStatus_OlderEmptyStatusCannotOverrideNewerPreparedStatus()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess);
workflow.ObserveEngineStatus(engine.Status with
{
PreparedSceneName = "5001",
OnAirSceneName = null,
Sequence = 20
});
workflow.ObserveEngineStatus(engine.Status with
{
PreparedSceneName = null,
OnAirSceneName = null,
Sequence = 19
});
Assert.Equal("5001", workflow.State.PreparedCutCode);
Assert.Equal("one", workflow.State.CurrentEntryId);
}
private sealed class RecordingProvider(
IReadOnlyDictionary<string, (int Count, ScenePageSize? Size)> definitions)
: ILegacySceneCueProvider
@@ -360,6 +433,10 @@ public sealed class LegacyPlayoutWorkflowTests
public Func<string, int, string>? SceneNameOverride { get; init; }
public string? BuilderKey { get; init; }
public IReadOnlyList<LegacyScenePreviewField>? PreviewFields { get; init; }
public Task<LegacySceneCuePage> CreatePageAsync(
LegacyPlaylistEntry entry,
int pageIndexZeroBased,
@@ -375,7 +452,9 @@ public sealed class LegacyPlayoutWorkflowTests
name,
Mutations: [new PlayoutSetValue("page", $"p{pageIndexZeroBased}")]),
definition.Count,
definition.Size));
definition.Size,
BuilderKey,
PreviewFields));
}
}
@@ -387,6 +466,8 @@ public sealed class LegacyPlayoutWorkflowTests
public Queue<PlayoutResultCode> UpdateOnAirResults { get; } = [];
public Action? PrepareCallback { get; set; }
public PlayoutStatus Status { get; } = new(
PlayoutMode.DryRun,
PlayoutConnectionState.DryRunReady,
@@ -417,6 +498,7 @@ public sealed class LegacyPlayoutWorkflowTests
CancellationToken cancellationToken = default)
{
Calls.Add("Prepare:" + Describe(cue));
PrepareCallback?.Invoke();
return Complete(PlayoutOperation.Prepare, PrepareResults);
}