using System.Threading.Channels;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.LocalDb;
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging;
///
/// Records operational events into the consolidated site database (LocalDb Phase 1).
/// Only the active node generates events, but site_events is a replicated table:
/// when the pair has a peer configured, the standby holds the same history and a failover
/// no longer starts a fresh log. With no peer configured the database is simply local —
/// replication is opt-in via LocalDb:Replication:PeerAddress.
///
///
///
/// A single is owned here and is NOT thread-safe.
/// All access — recording, querying, purging — must be funnelled through
/// , which serialises callers on a shared lock.
///
///
/// Event recording is offloaded to a dedicated background writer thread (fed by a
/// bounded ; capacity ,
/// default 10 000, overflow ).
/// only validates its arguments and enqueues, so callers —
/// typically Akka actor threads on hot paths — never block on disk I/O or on
/// contention for the write lock. The returned completes once the
/// event is durably persisted and faults if the write fails. When a queued
/// event is evicted to make room for a newer one, that event's Task
/// is faulted with and
/// is incremented so the drop is observable.
///
///
public class SiteEventLogger : ISiteEventLogger, IDisposable
{
private readonly SqliteConnection _connection;
private readonly ILogger _logger;
private readonly object _writeLock = new();
private readonly Channel _writeQueue;
private readonly Task _writerLoop;
private long _failedWriteCount;
private bool _disposed;
///
/// Initializes the event logger over the consolidated site database and starts the
/// background writer loop.
///
/// Site event log configuration (retention settings, queue capacity).
/// Logger for write-failure diagnostics.
///
/// The consolidated site database. The connection it hands out is already open and
/// carries the zb_hlc_next() UDF that site_events' capture triggers
/// call. This logger owns the connection for its lifetime and disposes it.
///
///
/// The former connectionStringOverride seam is deliberately gone rather than
/// preserved. site_events is a replicated table, so a raw connection lacking
/// the UDF would fail closed on every insert — an override would only let a caller
/// build a logger that cannot write. SiteEventLogOptions.DatabasePath is
/// likewise no longer read: the location is LocalDb:Path.
///
public SiteEventLogger(
IOptions options,
ILogger logger,
ILocalDb localDb)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(localDb);
_logger = logger;
// Already open — CreateConnection returns a live, pragma-configured,
// UDF-registered connection. Calling Open() on it again would throw.
_connection = localDb.CreateConnection();
InitializeSchema();
// Bounded queue with DropOldest preserves the
// "callers never block" guarantee while putting an
// upper bound on memory under sustained writer slowness. Drops are
// observable — itemDropped faults the evicted Task and increments
// FailedWriteCount.
var capacity = Math.Max(1, options.Value.WriteQueueCapacity);
_writeQueue = Channel.CreateBounded(
new BoundedChannelOptions(capacity)
{
SingleReader = true,
SingleWriter = false,
FullMode = BoundedChannelFullMode.DropOldest,
},
itemDropped: dropped =>
{
Interlocked.Increment(ref _failedWriteCount);
dropped.Completion.TrySetException(
new InvalidOperationException(
$"Event was dropped because the write queue exceeded its bounded capacity ({capacity})."));
});
_writerLoop = Task.Run(ProcessWriteQueueAsync);
}
///
public long FailedWriteCount => Interlocked.Read(ref _failedWriteCount);
///
/// Runs against the shared connection while holding the
/// write lock, so purge / query / record callers on different threads never use
/// the non-thread-safe concurrently.
/// Returns without invoking the action if the logger has
/// been disposed.
///
/// The action to run against the shared connection.
/// if the action was executed; if the logger is disposed.
internal bool WithConnection(Action action)
{
ArgumentNullException.ThrowIfNull(action);
lock (_writeLock)
{
if (_disposed) return false;
action(_connection);
return true;
}
}
///
/// Runs against the shared connection while holding the
/// write lock. Throws if the logger has
/// been disposed (callers that need a result cannot proceed without the database).
///
/// The return type of the function.
/// The function to run against the shared connection.
/// The value returned by .
internal T WithConnection(Func func)
{
ArgumentNullException.ThrowIfNull(func);
lock (_writeLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
return func(_connection);
}
}
// Schema lives in SiteEventLogSchema so the Host's AddZbLocalDb onReady callback
// can create this table in the consolidated site database before RegisterReplicated
// installs its capture triggers. Applying it here too keeps a directly-constructed
// logger (tests, tooling) self-sufficient; the DDL is idempotent.
private void InitializeSchema() => SiteEventLogSchema.Apply(_connection);
///
/// Closed set of allowed severities. Case-sensitive to
/// match the SQLite default BINARY collation used by the query filter —
/// a row stored as "error" would be invisible to a query filtering on
/// "Error", so the contract on the way in must match the contract on
/// the way out.
///
private static readonly HashSet AllowedSeverities =
new(StringComparer.Ordinal) { "Info", "Warning", "Error" };
///
public Task LogEventAsync(
string eventType,
string severity,
string? instanceId,
string source,
string message,
string? details = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(eventType);
ArgumentException.ThrowIfNullOrWhiteSpace(severity);
ArgumentException.ThrowIfNullOrWhiteSpace(source);
ArgumentException.ThrowIfNullOrWhiteSpace(message);
// Reject unknown severities so the query-time filter
// (case-sensitive BINARY collation) and the documented enum stay in sync.
if (!AllowedSeverities.Contains(severity))
{
throw new ArgumentException(
$"Severity '{severity}' is not one of the allowed values: Info, Warning, Error.",
nameof(severity));
}
// The id is minted here, by the application, rather than by SQLite. Under
// LocalDb replication the site pair converges by last-writer-wins on the
// primary key, so a server-minted AUTOINCREMENT would have both nodes
// independently issuing id 1, 2, 3... for unrelated events and silently
// overwriting each other on sync. A GUID makes the event log a pure union.
var pending = new PendingEvent(
Guid.NewGuid().ToString("N"),
DateTimeOffset.UtcNow.ToString("o"),
eventType,
severity,
instanceId,
source,
message,
details);
// Enqueue only — the actual SQLite write happens on the background writer
// thread so the caller (an Akka actor thread on a hot path) never blocks
// on disk I/O or on contention for the write lock.
if (!_writeQueue.Writer.TryWrite(pending))
{
// The channel is bounded with DropOldest, so TryWrite only returns false
// once the channel is completed (logger disposed) — full-buffer pressure
// evicts the oldest pending event instead of failing the write. The event
// cannot be persisted — fault the Task rather than reporting false success,
// so a caller that awaits a critical audit event can tell it was dropped.
pending.Completion.TrySetException(
new ObjectDisposedException(nameof(SiteEventLogger),
"Event could not be recorded: the event logger has been disposed."));
}
return pending.Completion.Task;
}
private async Task ProcessWriteQueueAsync()
{
await foreach (var pending in _writeQueue.Reader.ReadAllAsync().ConfigureAwait(false))
{
try
{
var written = WithConnection(connection =>
{
using var cmd = connection.CreateCommand();
cmd.CommandText = """
INSERT INTO site_events (id, timestamp, event_type, severity, instance_id, source, message, details)
VALUES ($id, $timestamp, $event_type, $severity, $instance_id, $source, $message, $details)
""";
cmd.Parameters.AddWithValue("$id", pending.Id);
cmd.Parameters.AddWithValue("$timestamp", pending.Timestamp);
cmd.Parameters.AddWithValue("$event_type", pending.EventType);
cmd.Parameters.AddWithValue("$severity", pending.Severity);
cmd.Parameters.AddWithValue("$instance_id", (object?)pending.InstanceId ?? DBNull.Value);
cmd.Parameters.AddWithValue("$source", pending.Source);
cmd.Parameters.AddWithValue("$message", pending.Message);
cmd.Parameters.AddWithValue("$details", (object?)pending.Details ?? DBNull.Value);
cmd.ExecuteNonQuery();
});
if (written)
{
pending.Completion.TrySetResult();
}
else
{
// WithConnection returns false only when the logger has been
// disposed mid-drain; the event was not persisted. Fault the
// Task instead of reporting false
// success for a dropped audit event.
pending.Completion.TrySetException(
new ObjectDisposedException(nameof(SiteEventLogger),
"Event could not be recorded: the event logger was disposed before the write completed."));
}
}
catch (Exception ex)
{
// A write failure must be observable. Count it
// (Health Monitoring reads FailedWriteCount) and fault the caller's
// Task instead of silently discarding the exception.
Interlocked.Increment(ref _failedWriteCount);
_logger.LogError(ex, "Failed to record event: {EventType} from {Source} (sqlite {SqliteError})",
pending.EventType, pending.Source, DescribeSqliteError(ex));
pending.Completion.TrySetException(ex);
}
}
}
///
/// Stops accepting new events, drains the write queue, and disposes the SQLite connection.
///
public void Dispose()
{
Task? writerLoop = null;
lock (_writeLock)
{
if (_disposed) return;
_disposed = true;
// Stop accepting new events and let the writer loop drain.
_writeQueue.Writer.TryComplete();
writerLoop = _writerLoop;
}
// Wait for the writer loop to finish outside the lock — the loop itself
// acquires the lock for each write.
try
{
writerLoop?.Wait(TimeSpan.FromSeconds(5));
}
catch (AggregateException)
{
// A faulted writer loop has already been logged per event; nothing more
// to do during disposal.
}
lock (_writeLock)
{
_connection.Dispose();
}
}
///
/// "primary/extended" SQLite result codes (e.g. "10/522") for a
/// anywhere in the chain; "n/a" otherwise.
/// The exception message alone carries only the primary code, which proved
/// too generic to diagnose the 2026-07-20 disk-I/O incident
/// (known-issues/2026-07-20-localdb-disk-io-error-under-load.md).
///
private static string DescribeSqliteError(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
if (e is SqliteException se)
{
return $"{se.SqliteErrorCode}/{se.SqliteExtendedErrorCode}";
}
}
return "n/a";
}
/// An event awaiting persistence by the background writer.
private sealed record PendingEvent(
string Id,
string Timestamp,
string EventType,
string Severity,
string? InstanceId,
string Source,
string Message,
string? Details)
{
/// Completes when the event has been durably persisted, or faults on write failure.
public TaskCompletionSource Completion { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
}
}