fix(options): eager startup validation for AuditLog central sub-options — PartitionMaintenance, Purge, Reconciliation (plan R2-08 T6, arch-review 08r2 NF4)

This commit is contained in:
Joseph Doherty
2026-07-13 10:39:03 -04:00
parent e0dddac0be
commit f85f036b09
7 changed files with 241 additions and 6 deletions
@@ -0,0 +1,25 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Validates <see cref="AuditLogPartitionMaintenanceOptions"/> at host startup
/// (arch-review 08 round 2 NF4). A zero tick interval spins the maintenance
/// hosted service; a zero look-ahead leaves <c>pf_AuditLog_Month</c> without a
/// future monthly boundary, risking inserts into the unbounded tail partition.
/// </summary>
public sealed class AuditLogPartitionMaintenanceOptionsValidator
: OptionsValidatorBase<AuditLogPartitionMaintenanceOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, AuditLogPartitionMaintenanceOptions options)
{
builder.RequireThat(options.IntervalSeconds > 0,
$"AuditLog:PartitionMaintenance:{nameof(AuditLogPartitionMaintenanceOptions.IntervalSeconds)} " +
$"({options.IntervalSeconds}) must be > 0.");
builder.RequireThat(options.LookaheadMonths > 0,
$"AuditLog:PartitionMaintenance:{nameof(AuditLogPartitionMaintenanceOptions.LookaheadMonths)} " +
$"({options.LookaheadMonths}) must be > 0.");
}
}
@@ -0,0 +1,31 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Validates <see cref="AuditLogPurgeOptions"/> at host startup (arch-review 08
/// round 2 NF4). The class self-clamps at resolve time, but a plainly-wrong
/// config (zero cadence, zero batch, zero maintenance-command timeout) should
/// still fail the boot so the operator learns immediately.
/// <see cref="AuditLogPurgeOptions.IntervalOverride"/> is test-only (per the
/// class remarks) and is deliberately not validated.
/// </summary>
public sealed class AuditLogPurgeOptionsValidator : OptionsValidatorBase<AuditLogPurgeOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, AuditLogPurgeOptions options)
{
builder.RequireThat(options.IntervalHours > 0,
$"AuditLog:Purge:{nameof(AuditLogPurgeOptions.IntervalHours)} " +
$"({options.IntervalHours}) must be > 0.");
// Backing field for the documented "ChannelPurgeBatchSize" config key.
builder.RequireThat(options.ChannelPurgeBatchSizeConfigured > 0,
$"AuditLog:Purge:{nameof(AuditLogPurgeOptions.ChannelPurgeBatchSizeConfigured)} " +
$"(config key ChannelPurgeBatchSize = {options.ChannelPurgeBatchSizeConfigured}) must be > 0.");
builder.RequireThat(options.MaintenanceCommandTimeoutMinutes > 0,
$"AuditLog:Purge:{nameof(AuditLogPurgeOptions.MaintenanceCommandTimeoutMinutes)} " +
$"({options.MaintenanceCommandTimeoutMinutes}) must be > 0.");
}
}
@@ -0,0 +1,31 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
/// <summary>
/// Validates <see cref="SiteAuditReconciliationOptions"/> at host startup
/// (arch-review 08 round 2 NF4). A zero tick interval spins the reconciliation
/// singleton, a zero batch pulls nothing per RPC, and a zero stalled-cycle
/// threshold would fire the stalled signal on the first non-draining cycle.
/// <see cref="SiteAuditReconciliationOptions.ReconciliationIntervalOverride"/>
/// is test-only (per the class remarks) and is deliberately not validated.
/// </summary>
public sealed class SiteAuditReconciliationOptionsValidator
: OptionsValidatorBase<SiteAuditReconciliationOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, SiteAuditReconciliationOptions options)
{
builder.RequireThat(options.ReconciliationIntervalSeconds > 0,
$"AuditLog:Reconciliation:{nameof(SiteAuditReconciliationOptions.ReconciliationIntervalSeconds)} " +
$"({options.ReconciliationIntervalSeconds}) must be > 0.");
builder.RequireThat(options.BatchSize > 0,
$"AuditLog:Reconciliation:{nameof(SiteAuditReconciliationOptions.BatchSize)} " +
$"({options.BatchSize}) must be > 0.");
builder.RequireThat(options.StalledAfterNonDrainingCycles > 0,
$"AuditLog:Reconciliation:{nameof(SiteAuditReconciliationOptions.StalledAfterNonDrainingCycles)} " +
$"({options.StalledAfterNonDrainingCycles}) must be > 0.");
}
}
@@ -368,8 +368,8 @@ public static class ServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config); ArgumentNullException.ThrowIfNull(config);
services.AddOptions<AuditLogPartitionMaintenanceOptions>() services.AddValidatedOptions<AuditLogPartitionMaintenanceOptions,
.Bind(config.GetSection(PartitionMaintenanceSectionName)); AuditLogPartitionMaintenanceOptionsValidator>(config, PartitionMaintenanceSectionName);
services.AddHostedService<AuditLogPartitionMaintenanceService>(); services.AddHostedService<AuditLogPartitionMaintenanceService>();
// I1 (review): bind the two central-singleton options HERE rather than in // I1 (review): bind the two central-singleton options HERE rather than in
@@ -385,10 +385,10 @@ public static class ServiceCollectionExtensions
// 5 min reconciliation tick); production exposes IntervalHours / // 5 min reconciliation tick); production exposes IntervalHours /
// ReconciliationIntervalSeconds only — the test-only *Override knobs are // ReconciliationIntervalSeconds only — the test-only *Override knobs are
// not intended to be set from config (see the options classes' remarks). // not intended to be set from config (see the options classes' remarks).
services.AddOptions<AuditLogPurgeOptions>() services.AddValidatedOptions<AuditLogPurgeOptions, AuditLogPurgeOptionsValidator>(
.Bind(config.GetSection(PurgeSectionName)); config, PurgeSectionName);
services.AddOptions<SiteAuditReconciliationOptions>() services.AddValidatedOptions<SiteAuditReconciliationOptions, SiteAuditReconciliationOptionsValidator>(
.Bind(config.GetSection(ReconciliationSectionName)); config, ReconciliationSectionName);
// Central health snapshot — a single object // Central health snapshot — a single object
// that owns the CentralAuditWriteFailures + AuditRedactionFailure // that owns the CentralAuditWriteFailures + AuditRedactionFailure
@@ -0,0 +1,41 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Eager startup validation for <see cref="AuditLogPartitionMaintenanceOptions"/>
/// (arch-review 08 round 2 NF4). A zero tick interval spins the maintenance
/// hosted service; a zero look-ahead leaves the partition function without a
/// future monthly boundary, risking inserts into the unbounded tail partition.
/// </summary>
public class AuditLogPartitionMaintenanceOptionsValidatorTests
{
[Fact]
public void DefaultOptions_AreValid()
{
var validator = new AuditLogPartitionMaintenanceOptionsValidator();
Assert.True(validator.Validate(null, new AuditLogPartitionMaintenanceOptions()).Succeeded);
}
[Fact]
public void ZeroIntervalSeconds_Fails()
{
var validator = new AuditLogPartitionMaintenanceOptionsValidator();
var result = validator.Validate(null,
new AuditLogPartitionMaintenanceOptions { IntervalSeconds = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(AuditLogPartitionMaintenanceOptions.IntervalSeconds), StringComparison.Ordinal));
}
[Fact]
public void ZeroLookaheadMonths_Fails()
{
var validator = new AuditLogPartitionMaintenanceOptionsValidator();
var result = validator.Validate(null,
new AuditLogPartitionMaintenanceOptions { LookaheadMonths = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(AuditLogPartitionMaintenanceOptions.LookaheadMonths), StringComparison.Ordinal));
}
}
@@ -0,0 +1,53 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Eager startup validation for <see cref="AuditLogPurgeOptions"/>
/// (arch-review 08 round 2 NF4). The class self-clamps at resolve time, but a
/// plainly-wrong config (zero cadence, zero batch, zero command timeout) should
/// still fail the boot so the operator learns immediately.
/// <see cref="AuditLogPurgeOptions.IntervalOverride"/> is test-only and left
/// unvalidated.
/// </summary>
public class AuditLogPurgeOptionsValidatorTests
{
[Fact]
public void DefaultOptions_AreValid()
{
var validator = new AuditLogPurgeOptionsValidator();
Assert.True(validator.Validate(null, new AuditLogPurgeOptions()).Succeeded);
}
[Fact]
public void ZeroIntervalHours_Fails()
{
var validator = new AuditLogPurgeOptionsValidator();
var result = validator.Validate(null, new AuditLogPurgeOptions { IntervalHours = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(AuditLogPurgeOptions.IntervalHours), StringComparison.Ordinal));
}
[Fact]
public void ZeroChannelPurgeBatchSize_Fails()
{
var validator = new AuditLogPurgeOptionsValidator();
var result = validator.Validate(null,
new AuditLogPurgeOptions { ChannelPurgeBatchSizeConfigured = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(AuditLogPurgeOptions.ChannelPurgeBatchSizeConfigured), StringComparison.Ordinal));
}
[Fact]
public void ZeroMaintenanceCommandTimeout_Fails()
{
var validator = new AuditLogPurgeOptionsValidator();
var result = validator.Validate(null,
new AuditLogPurgeOptions { MaintenanceCommandTimeoutMinutes = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(AuditLogPurgeOptions.MaintenanceCommandTimeoutMinutes), StringComparison.Ordinal));
}
}
@@ -0,0 +1,54 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Eager startup validation for <see cref="SiteAuditReconciliationOptions"/>
/// (arch-review 08 round 2 NF4). A zero tick interval spins the reconciliation
/// singleton, a zero batch pulls nothing, and a zero stalled-cycle threshold
/// would fire the stalled signal on the first non-draining cycle.
/// <see cref="SiteAuditReconciliationOptions.ReconciliationIntervalOverride"/>
/// is test-only and left unvalidated.
/// </summary>
public class SiteAuditReconciliationOptionsValidatorTests
{
[Fact]
public void DefaultOptions_AreValid()
{
var validator = new SiteAuditReconciliationOptionsValidator();
Assert.True(validator.Validate(null, new SiteAuditReconciliationOptions()).Succeeded);
}
[Fact]
public void ZeroReconciliationInterval_Fails()
{
var validator = new SiteAuditReconciliationOptionsValidator();
var result = validator.Validate(null,
new SiteAuditReconciliationOptions { ReconciliationIntervalSeconds = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditReconciliationOptions.ReconciliationIntervalSeconds), StringComparison.Ordinal));
}
[Fact]
public void ZeroBatchSize_Fails()
{
var validator = new SiteAuditReconciliationOptionsValidator();
var result = validator.Validate(null,
new SiteAuditReconciliationOptions { BatchSize = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditReconciliationOptions.BatchSize), StringComparison.Ordinal));
}
[Fact]
public void ZeroStalledCycles_Fails()
{
var validator = new SiteAuditReconciliationOptionsValidator();
var result = validator.Validate(null,
new SiteAuditReconciliationOptions { StalledAfterNonDrainingCycles = 0 });
Assert.False(result.Succeeded);
Assert.Contains(result.Failures!,
f => f.Contains(nameof(SiteAuditReconciliationOptions.StalledAfterNonDrainingCycles), StringComparison.Ordinal));
}
}