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:
148
src/FrameProcessor/Configuration/FramesRegistry.cs
Normal file
148
src/FrameProcessor/Configuration/FramesRegistry.cs
Normal 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);
|
||||
}
|
||||
14
src/FrameProcessor/Domain/Frame.cs
Normal file
14
src/FrameProcessor/Domain/Frame.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace FrameProcessor.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A configured frame after validation and parsing. All raw strings from
|
||||
/// <c>frames.json</c> have been converted to their typed values; consumers can use
|
||||
/// the contents directly without re-validating.
|
||||
/// </summary>
|
||||
public sealed record Frame(
|
||||
FrameName Name,
|
||||
MacAddress Mac,
|
||||
Resolution Resolution,
|
||||
Orientation Orientation,
|
||||
DitherAlgorithm Dithering,
|
||||
IReadOnlyList<PaletteEntry> Palette);
|
||||
@@ -1,5 +1,4 @@
|
||||
using FrameProcessor.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -27,13 +26,20 @@ builder.Services.AddOptions<ApiKeyOptions>()
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton<IValidateOptions<FramesOptions>, FramesOptionsValidator>();
|
||||
// FramesOptions is bound but not validated via the options pipeline so that
|
||||
// IOptionsMonitor<FramesOptions> can fire OnChange with invalid content during
|
||||
// hot-reload without throwing. FramesRegistry takes responsibility for both
|
||||
// strict startup validation (in its constructor) and lenient reload validation.
|
||||
builder.Services.AddOptions<FramesOptions>()
|
||||
.Bind(builder.Configuration)
|
||||
.ValidateOnStart();
|
||||
.Bind(builder.Configuration);
|
||||
builder.Services.AddSingleton<FramesOptionsValidator>();
|
||||
builder.Services.AddSingleton<FramesRegistry>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Eagerly resolve FramesRegistry so an invalid frames.json fails startup fast.
|
||||
_ = app.Services.GetRequiredService<FramesRegistry>();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
|
||||
Reference in New Issue
Block a user