2.3 Startup vs reload asymmetry (FramesRegistry)

FramesRegistry validates strictly on construction (fail-fast at startup) and
leniently on hot-reload (skip invalid frames with a warning, keep valid ones
serving). Exposes TryGetByName/TryGetByMac over the current valid set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 14:27:20 +02:00
parent d0fa755534
commit 0dc0da8de1
5 changed files with 462 additions and 5 deletions

View File

@@ -0,0 +1,148 @@
using FrameProcessor.Domain;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SixLabors.ImageSharp;
namespace FrameProcessor.Configuration;
/// <summary>
/// Holds the current set of valid frames parsed from <c>frames.json</c> and exposes
/// lookups by <see cref="FrameName"/> and <see cref="MacAddress"/>. Enforces the
/// startup-vs-reload asymmetry from SPEC.md §6.2 and CLAUDE.md "frames.json reload
/// asymmetry": an invalid frame at startup fails fast; an invalid frame at hot-reload
/// is logged and skipped while the remaining valid frames keep serving.
/// </summary>
public sealed class FramesRegistry : IDisposable
{
private readonly FramesOptionsValidator _validator;
private readonly ILogger<FramesRegistry> _logger;
private readonly IDisposable? _changeSubscription;
private volatile FrameSet _frames;
public FramesRegistry(
IOptionsMonitor<FramesOptions> monitor,
FramesOptionsValidator validator,
ILogger<FramesRegistry> logger)
{
_validator = validator;
_logger = logger;
_frames = BuildStrict(monitor.CurrentValue);
_changeSubscription = monitor.OnChange(OnFramesChanged);
}
public IReadOnlyCollection<Frame> All => (IReadOnlyCollection<Frame>)_frames.ByName.Values;
public bool TryGetByName(FrameName name, out Frame frame)
=> _frames.ByName.TryGetValue(name, out frame!);
public bool TryGetByMac(MacAddress mac, out Frame frame)
=> _frames.ByMac.TryGetValue(mac, out frame!);
public void Dispose() => _changeSubscription?.Dispose();
private FrameSet BuildStrict(FramesOptions options)
{
var result = _validator.Validate(null, options);
if (result.Failed)
{
throw new OptionsValidationException(
nameof(FramesOptions),
typeof(FramesOptions),
result.Failures ?? Array.Empty<string>());
}
var byName = new Dictionary<FrameName, Frame>();
var byMac = new Dictionary<MacAddress, Frame>();
foreach (var frameOptions in options.Frames)
{
var frame = ToFrame(frameOptions);
byName[frame.Name] = frame;
byMac[frame.Mac] = frame;
}
return new FrameSet(byName, byMac);
}
private void OnFramesChanged(FramesOptions options)
{
var byName = new Dictionary<FrameName, Frame>();
var byMac = new Dictionary<MacAddress, Frame>();
for (var i = 0; i < options.Frames.Count; i++)
{
var frameOptions = options.Frames[i];
var single = new FramesOptions { Frames = { frameOptions } };
var result = _validator.Validate(null, single);
if (result.Failed)
{
_logger.LogWarning(
"Skipping invalid frame at Frames[{Index}] on reload: {Errors}",
i,
string.Join("; ", result.Failures ?? Array.Empty<string>()));
continue;
}
var frame = ToFrame(frameOptions);
if (byName.ContainsKey(frame.Name))
{
_logger.LogWarning(
"Skipping frame at Frames[{Index}] on reload: duplicate name '{Name}'.",
i,
frame.Name);
continue;
}
if (byMac.ContainsKey(frame.Mac))
{
_logger.LogWarning(
"Skipping frame at Frames[{Index}] on reload: duplicate MAC '{Mac}'.",
i,
frame.Mac);
continue;
}
byName[frame.Name] = frame;
byMac[frame.Mac] = frame;
}
_frames = new FrameSet(byName, byMac);
_logger.LogInformation("Reloaded frames; {Count} active.", byName.Count);
}
private static Frame ToFrame(FrameOptions opts)
{
var name = FrameName.Parse(opts.Name!);
var mac = MacAddress.Parse(opts.Mac!);
var resolution = new Resolution(opts.Resolution!.Width, opts.Resolution.Height);
var orientation = opts.Orientation switch
{
"landscape" => Orientation.Landscape,
"portrait" => Orientation.Portrait,
_ => throw new InvalidOperationException(
$"Validator allowed unexpected orientation '{opts.Orientation}'."),
};
var dithering = opts.Dithering switch
{
"floyd-steinberg" => DitherAlgorithm.FloydSteinberg,
"atkinson" => DitherAlgorithm.Atkinson,
"stucki" => DitherAlgorithm.Stucki,
"jarvis" => DitherAlgorithm.Jarvis,
_ => throw new InvalidOperationException(
$"Validator allowed unexpected dithering '{opts.Dithering}'."),
};
var palette = opts.Palette
.Select(p => new PaletteEntry(
p.Name!,
Color.ParseHex(p.Color!),
Color.ParseHex(p.DeviceColor!)))
.ToList();
return new Frame(name, mac, resolution, orientation, dithering, palette);
}
private sealed record FrameSet(
IReadOnlyDictionary<FrameName, Frame> ByName,
IReadOnlyDictionary<MacAddress, Frame> ByMac);
}