feat(audit): stamp SourceNode at site SqliteAuditWriter from INodeIdentityProvider

Caller-provided SourceNode wins (preserves reconciled rows from other nodes);
otherwise the writer fills it from the local INodeIdentityProvider.NodeName.
Reads from the provider on every write — singleton lifetime means zero overhead.
This commit is contained in:
Joseph Doherty
2026-05-23 17:08:21 -04:00
parent 277882d230
commit 479870e40c
14 changed files with 125 additions and 5 deletions

View File

@@ -42,6 +42,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
private readonly SqliteConnection _connection;
private readonly SqliteAuditWriterOptions _options;
private readonly ILogger<SqliteAuditWriter> _logger;
private readonly INodeIdentityProvider _nodeIdentity;
private readonly object _writeLock = new();
private readonly Channel<PendingAuditEvent> _writeQueue;
private readonly Task _writerLoop;
@@ -50,13 +51,16 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
public SqliteAuditWriter(
IOptions<SqliteAuditWriterOptions> options,
ILogger<SqliteAuditWriter> logger,
INodeIdentityProvider nodeIdentity,
string? connectionStringOverride = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(nodeIdentity);
_options = options.Value;
_logger = logger;
_nodeIdentity = nodeIdentity;
var connectionString = connectionStringOverride
?? $"Data Source={_options.DatabasePath};Cache=Shared";
@@ -325,7 +329,15 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
pKind.Value = e.Kind.ToString();
pCorrelationId.Value = (object?)e.CorrelationId?.ToString() ?? DBNull.Value;
pSourceSiteId.Value = (object?)e.SourceSiteId ?? DBNull.Value;
pSourceNode.Value = (object?)e.SourceNode ?? DBNull.Value;
// SourceNode-stamping: caller-provided value wins (preserves
// rows reconciled in from other nodes via the same writer);
// otherwise stamp from the local INodeIdentityProvider. The
// event record itself is NOT mutated — stamping is at write
// time only. If the provider also returns null (unconfigured
// node), the row's SourceNode stays NULL — operators see
// "needs config" via the schema, not a magic fallback string.
var sourceNode = e.SourceNode ?? _nodeIdentity.NodeName;
pSourceNode.Value = (object?)sourceNode ?? DBNull.Value;
pSourceInstanceId.Value = (object?)e.SourceInstanceId ?? DBNull.Value;
pSourceScript.Value = (object?)e.SourceScript ?? DBNull.Value;
pActor.Value = (object?)e.Actor ?? DBNull.Value;

View File

@@ -7,6 +7,7 @@ using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Configuration;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.AuditLog.Tests.TestSupport;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.HealthMonitoring;
@@ -31,6 +32,10 @@ public class AddAuditLogTests
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
// INodeIdentityProvider is registered by the Host's
// SiteServiceRegistration in production; AddAuditLog assumes its
// presence so SqliteAuditWriter and CentralAuditWriter can resolve.
services.AddSingleton<INodeIdentityProvider>(new FakeNodeIdentityProvider());
services.AddAuditLog(config);
return services.BuildServiceProvider();
}

View File

@@ -9,6 +9,7 @@ using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.AuditLog.Tests.Integration.Infrastructure;
using ScadaLink.AuditLog.Tests.TestSupport;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
@@ -114,6 +115,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
ChannelCapacity = 1024,
}),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride:
$"Data Source=file:auditlog-e1-{Guid.NewGuid():N}?mode=memory&cache=shared");

View File

@@ -9,6 +9,7 @@ using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.AuditLog.Tests.Integration.Infrastructure;
using ScadaLink.AuditLog.Tests.TestSupport;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types.Audit;
@@ -111,6 +112,7 @@ public class ExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigration
ChannelCapacity = 1024,
}),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride:
$"Data Source=file:auditlog-execid-{Guid.NewGuid():N}?mode=memory&cache=shared");

View File

@@ -10,6 +10,7 @@ using ScadaLink.Commons.Interfaces;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Integration;
using ScadaLink.AuditLog.Tests.TestSupport;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
@@ -78,6 +79,7 @@ public sealed class CombinedTelemetryHarness : IAsyncDisposable
ChannelCapacity = 1024,
}),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride:
$"Data Source=file:cachedcall-g-{Guid.NewGuid():N}?mode=memory&cache=shared");

View File

@@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Tests.TestSupport;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
@@ -148,6 +149,7 @@ public class OutageReconciliationTests : TestKit, IClassFixture<MsSqlMigrationFi
ChannelCapacity = 4096,
}),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride:
$"Data Source=file:outage-{Guid.NewGuid():N}?mode=memory&cache=shared");

View File

@@ -16,6 +16,7 @@ using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.AuditLog.Tests.Integration.Infrastructure;
using ScadaLink.AuditLog.Tests.TestSupport;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
@@ -166,6 +167,7 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMig
ChannelCapacity = 1024,
}),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride:
$"Data Source=file:auditlog-parentexec-{Guid.NewGuid():N}?mode=memory&cache=shared");
var ring = new RingBufferFallback();

View File

@@ -7,6 +7,7 @@ using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.AuditLog.Tests.Integration.Infrastructure;
using ScadaLink.AuditLog.Tests.TestSupport;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
@@ -91,12 +92,13 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
});
private static SqliteAuditWriter CreateInMemorySqliteWriter() =>
// The 3rd constructor argument is connectionStringOverride. A unique
// The 4th constructor argument is connectionStringOverride. A unique
// shared-cache in-memory URI keeps the schema scoped to this writer
// instance and torn down when the writer is disposed.
new SqliteAuditWriter(
InMemorySqliteOptions(),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride: $"Data Source=file:auditlog-h-{Guid.NewGuid():N}?mode=memory&cache=shared");
private static IOptions<SiteAuditTelemetryOptions> FastTelemetryOptions() =>

View File

@@ -11,6 +11,7 @@ using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Configuration;
using ScadaLink.AuditLog.Payload;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Tests.TestSupport;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
@@ -89,6 +90,7 @@ public class FilterIntegrationTests
var sqliteWriter = new SqliteAuditWriter(
Microsoft.Extensions.Options.Options.Create(new SqliteAuditWriterOptions { DatabasePath = dataSource }),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
await using var _disposeSqlite = sqliteWriter;

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Tests.TestSupport;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums;
@@ -39,7 +40,8 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
var options = new SqliteAuditWriterOptions { DatabasePath = _dbPath };
return new SqliteAuditWriter(
Options.Create(options),
NullLogger<SqliteAuditWriter>.Instance);
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider());
}
private static AuditEvent NewEvent(DateTime? occurredAtUtc = null) => new()

View File

@@ -2,6 +2,7 @@ using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Tests.TestSupport;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums;
@@ -31,6 +32,7 @@ public class SqliteAuditWriterSchemaTests
var writer = new SqliteAuditWriter(
Options.Create(options),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
return (writer, dataSource);
}
@@ -200,6 +202,7 @@ public class SqliteAuditWriterSchemaTests
return new SqliteAuditWriter(
Options.Create(options),
NullLogger<SqliteAuditWriter>.Instance,
new FakeNodeIdentityProvider(),
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
}

View File

@@ -2,7 +2,9 @@ using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Tests.TestSupport;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.AuditLog.Tests.Site;
@@ -19,7 +21,8 @@ public class SqliteAuditWriterWriteTests
{
private static (SqliteAuditWriter writer, string dataSource) CreateWriter(
string testName,
int? channelCapacity = null)
int? channelCapacity = null,
INodeIdentityProvider? nodeIdentity = null)
{
var dataSource = $"file:{testName}-{Guid.NewGuid():N}?mode=memory&cache=shared";
var opts = new SqliteAuditWriterOptions { DatabasePath = dataSource };
@@ -28,9 +31,15 @@ public class SqliteAuditWriterWriteTests
opts.ChannelCapacity = cap;
}
// Default identity provider returns null — existing tests pre-date
// SourceNode stamping and have no expectation about it. New stamping
// tests pass a real provider via the parameter.
var identity = nodeIdentity ?? new FakeNodeIdentityProvider();
var writer = new SqliteAuditWriter(
Options.Create(opts),
NullLogger<SqliteAuditWriter>.Instance,
identity,
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
return (writer, dataSource);
}
@@ -386,4 +395,57 @@ public class SqliteAuditWriterWriteTests
var row = Assert.Single(rows);
Assert.Null(row.ExecutionId);
}
// ----- SourceNode stamping (Tasks 11/12) ----- //
[Fact]
public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone()
{
var (writer, _) = CreateWriter(
nameof(WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone),
nodeIdentity: new FakeNodeIdentityProvider("node-a"));
await using var _w = writer;
var evt = NewEvent();
Assert.Null(evt.SourceNode); // sanity check — fresh event has no SourceNode
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal("node-a", row.SourceNode);
}
[Fact]
public async Task WriteAsync_PreservesCallerProvidedSourceNode()
{
var (writer, _) = CreateWriter(
nameof(WriteAsync_PreservesCallerProvidedSourceNode),
nodeIdentity: new FakeNodeIdentityProvider("node-a"));
await using var _w = writer;
// Reconciled rows from another node arrive with their origin's
// SourceNode already populated; the writer must preserve it.
var evt = NewEvent() with { SourceNode = "node-z" };
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal("node-z", row.SourceNode);
}
[Fact]
public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull()
{
var (writer, _) = CreateWriter(
nameof(WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull),
nodeIdentity: new FakeNodeIdentityProvider(nodeName: null));
await using var _w = writer;
var evt = NewEvent();
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Null(row.SourceNode);
}
}

View File

@@ -0,0 +1,20 @@
using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.AuditLog.Tests.TestSupport;
/// <summary>
/// Test fake for <see cref="INodeIdentityProvider"/>. Returns the configured
/// <see cref="NodeName"/> verbatim — including <c>null</c> — so tests can
/// exercise both the "stamped" and "unconfigured" branches of the SourceNode
/// stamping logic in <see cref="ScadaLink.AuditLog.Site.SqliteAuditWriter"/>
/// and <see cref="ScadaLink.AuditLog.Central.CentralAuditWriter"/>.
/// </summary>
internal sealed class FakeNodeIdentityProvider : INodeIdentityProvider
{
public string? NodeName { get; }
public FakeNodeIdentityProvider(string? nodeName = null)
{
NodeName = nodeName;
}
}

View File

@@ -146,8 +146,10 @@ public class SiteAuditPushFlowTests : TestKit
// + Pending queue). A temp file so it survives across DI scopes.
var dbPath = Path.Combine(Path.GetTempPath(), $"auditpush-{Guid.NewGuid():N}.db");
var writerOptions = Options.Create(new SqliteAuditWriterOptions { DatabasePath = dbPath });
var nodeIdentity = Substitute.For<ScadaLink.Commons.Interfaces.Services.INodeIdentityProvider>();
nodeIdentity.NodeName.Returns((string?)null);
await using var writer = new SqliteAuditWriter(
writerOptions, NullLogger<SqliteAuditWriter>.Instance);
writerOptions, NullLogger<SqliteAuditWriter>.Instance, nodeIdentity);
// Real SiteCommunicationActor. RegisterCentralClient is given the relay
// standing in for the central ClusterClient.