2e4e41a8f7
Closes WP1.2 of the arch-review remediation plan (finding #2, High): SqliteAuditWriterOptions.DatabasePath defaulted to CWD-relative "auditlog.db", which on the docker rig resolves onto the container's ephemeral overlayfs (not the mounted /app/data volume), silently discarding the pending audit forward-state backlog on every recreate; nothing in docker/ or docker-env2/ overrode it; FlushIntervalMs was validated but never read by the writer loop (one commit per event even at trickle rate); and no PRAGMA synchronous was set (SQLite's FULL default fsyncs every commit). - DatabasePath now has no default (mirrors ZB.MOM.WW.LocalDb's LocalDbOptions.Path) and is required pre-host for Site nodes only, via a new StartupValidator raw-config check (top-level "AuditLog:SiteWriter:DatabasePath", NOT nested under ScadaBridge: AddAuditLog binds that section off the configuration root). SqliteAuditWriterOptionsValidator deliberately does NOT check DatabasePath itself, because AddAuditLog runs its ValidateOnStart on both Central and Site composition roots but only Site nodes ever resolve the writer — checking it there would fail Central's boot too. - All 8 site-node appsettings under docker/ and docker-env2/ now set AuditLog:SiteWriter:DatabasePath to /app/data/auditlog.db (mounted volume, survives container recreate, same convention as LocalDb:Path); the local-dev base appsettings.Site.json sets ./data/auditlog.db to match. - The writer loop now honors FlushIntervalMs: after draining the immediately available burst, it keeps the transaction open (bounded by FlushIntervalMs from the first event) waiting for more trickle-rate events before committing, instead of flushing (and fsyncing) per event. - PRAGMA synchronous = NORMAL on the write connection — audit is best-effort by design (CLAUDE.md: "Audit-write failure NEVER aborts the user-facing action"), so NORMAL's narrower power-loss window is an acceptable trade for far fewer fsyncs; WAL mode still guarantees no corruption. - Tests: StartupValidator site-required/blank/central-exempt cases; writer trickle-load single-transaction coalescing + beyond-interval separate-transaction regression (new FlushCountForTests seam); options-validator doc updates reflecting the moved responsibility. Full suite runs green: AuditLog.Tests 368/368, Host.Tests 480/480. One-time migration note: the existing container-local auditlog.db (wherever it landed under CWD) is abandoned by this change, not migrated — already-forwarded rows are safe centrally (AuditLog is the durable copy), and any still-Pending rows on the abandoned path are lost once. This is the exact bug being fixed, not a new loss: those rows were already living outside the mounted volume and would not have survived the next container recreate regardless. Cross-reference docs/known-issues/2026-07-20-cached-telemetry-drain-hot-loop.md, which this placement bug caused.
73 lines
3.1 KiB
C#
73 lines
3.1 KiB
C#
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
|
|
|
/// <summary>
|
|
/// Eager startup validation for <see cref="SqliteAuditWriterOptions"/>
|
|
/// (arch-review 08 round 2 NF4). The site hot-path writer's channel/batch/flush
|
|
/// knobs must be positive or the background writer task cannot make progress.
|
|
/// <c>BacklogPollIntervalSeconds</c> is deliberately NOT validated — a
|
|
/// non-positive value has a documented fall-back-to-30s contract.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <c>DatabasePath</c> is intentionally NOT covered here (arch-review remediation
|
|
/// WP1.2): it defaults to <c>""</c> (no CWD-relative default — mirrors
|
|
/// <c>LocalDb:Path</c>), but this validator runs on both Central and Site
|
|
/// composition roots via <c>ValidateOnStart</c>, and only Site nodes ever resolve
|
|
/// the writer. The Site-only requirement is enforced pre-host by
|
|
/// <c>StartupValidator</c> in ZB.MOM.WW.ScadaBridge.Host — see
|
|
/// <c>StartupValidatorTests.SiteWithoutAuditLogDatabasePath_FailsValidation</c>.
|
|
/// </remarks>
|
|
public class SqliteAuditWriterOptionsValidatorTests
|
|
{
|
|
[Fact]
|
|
public void DefaultOptions_AreValid()
|
|
{
|
|
// Includes the empty DatabasePath default — this validator does not check
|
|
// it (see class remarks); only StartupValidator does, and only for Site nodes.
|
|
var validator = new SqliteAuditWriterOptionsValidator();
|
|
Assert.True(validator.Validate(null, new SqliteAuditWriterOptions()).Succeeded);
|
|
}
|
|
|
|
[Fact]
|
|
public void ZeroChannelCapacity_Fails()
|
|
{
|
|
var validator = new SqliteAuditWriterOptionsValidator();
|
|
var result = validator.Validate(null, new SqliteAuditWriterOptions { ChannelCapacity = 0 });
|
|
Assert.False(result.Succeeded);
|
|
Assert.Contains(result.Failures!,
|
|
f => f.Contains(nameof(SqliteAuditWriterOptions.ChannelCapacity), StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void ZeroBatchSize_Fails()
|
|
{
|
|
var validator = new SqliteAuditWriterOptionsValidator();
|
|
var result = validator.Validate(null, new SqliteAuditWriterOptions { BatchSize = 0 });
|
|
Assert.False(result.Succeeded);
|
|
Assert.Contains(result.Failures!,
|
|
f => f.Contains(nameof(SqliteAuditWriterOptions.BatchSize), StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void ZeroFlushInterval_Fails()
|
|
{
|
|
var validator = new SqliteAuditWriterOptionsValidator();
|
|
var result = validator.Validate(null, new SqliteAuditWriterOptions { FlushIntervalMs = 0 });
|
|
Assert.False(result.Succeeded);
|
|
Assert.Contains(result.Failures!,
|
|
f => f.Contains(nameof(SqliteAuditWriterOptions.FlushIntervalMs), StringComparison.Ordinal));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(-1)]
|
|
public void NonPositiveBacklogPollInterval_StillValid(int value)
|
|
{
|
|
// Documented fall-back-to-30s contract — must NOT be rejected.
|
|
var validator = new SqliteAuditWriterOptionsValidator();
|
|
Assert.True(validator.Validate(null,
|
|
new SqliteAuditWriterOptions { BacklogPollIntervalSeconds = value }).Succeeded);
|
|
}
|
|
}
|