Phase 3B: Site I/O & Observability — Communication, DCL, Script/Alarm actors, Health, Event Logging

Communication Layer (WP-1–5):
- 8 message patterns with correlation IDs, per-pattern timeouts
- Central/Site communication actors, transport heartbeat config
- Connection failure handling (no central buffering, debug streams killed)

Data Connection Layer (WP-6–14, WP-34):
- Connection actor with Become/Stash lifecycle (Connecting/Connected/Reconnecting)
- OPC UA + LmxProxy adapters behind IDataConnection
- Auto-reconnect, bad quality propagation, transparent re-subscribe
- Write-back, tag path resolution with retry, health reporting
- Protocol extensibility via DataConnectionFactory

Site Runtime (WP-15–25, WP-32–33):
- ScriptActor/ScriptExecutionActor (triggers, concurrent execution, blocking I/O dispatcher)
- AlarmActor/AlarmExecutionActor (ValueMatch/RangeViolation/RateOfChange, in-memory state)
- SharedScriptLibrary (inline execution), ScriptRuntimeContext (API)
- ScriptCompilationService (Roslyn, forbidden API enforcement, execution timeout)
- Recursion limit (default 10), call direction enforcement
- SiteStreamManager (per-subscriber bounded buffers, fire-and-forget)
- Debug view backend (snapshot + stream), concurrency serialization
- Local artifact storage (4 SQLite tables)

Health Monitoring (WP-26–28):
- SiteHealthCollector (thread-safe counters, connection state)
- HealthReportSender (30s interval, monotonic sequence numbers)
- CentralHealthAggregator (offline detection 60s, online recovery)

Site Event Logging (WP-29–31):
- SiteEventLogger (SQLite, 6 event categories, ISO 8601 UTC)
- EventLogPurgeService (30-day retention, 1GB cap)
- EventLogQueryService (filters, keyword search, keyset pagination)

541 tests pass, zero warnings.
This commit is contained in:
Joseph Doherty
2026-03-16 20:57:25 -04:00
parent a3bf0c43f3
commit 389f5a0378
97 changed files with 8308 additions and 127 deletions

View File

@@ -0,0 +1,146 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Messages.RemoteQuery;
namespace ScadaLink.SiteEventLogging;
/// <summary>
/// Processes event log queries locally against SQLite.
/// Supports filtering by event_type, time range, instance_id, severity,
/// and keyword search (LIKE on message and source).
/// Uses keyset pagination with continuation token (last event ID).
/// </summary>
public class EventLogQueryService : IEventLogQueryService
{
private readonly SiteEventLogger _eventLogger;
private readonly SiteEventLogOptions _options;
private readonly ILogger<EventLogQueryService> _logger;
public EventLogQueryService(
ISiteEventLogger eventLogger,
IOptions<SiteEventLogOptions> options,
ILogger<EventLogQueryService> logger)
{
_eventLogger = (SiteEventLogger)eventLogger;
_options = options.Value;
_logger = logger;
}
public EventLogQueryResponse ExecuteQuery(EventLogQueryRequest request)
{
try
{
var pageSize = request.PageSize > 0 ? request.PageSize : _options.QueryPageSize;
using var cmd = _eventLogger.Connection.CreateCommand();
var whereClauses = new List<string>();
var parameters = new List<SqliteParameter>();
// Keyset pagination: only return events with id > continuation token
if (request.ContinuationToken.HasValue)
{
whereClauses.Add("id > $afterId");
parameters.Add(new SqliteParameter("$afterId", request.ContinuationToken.Value));
}
if (request.From.HasValue)
{
whereClauses.Add("timestamp >= $from");
parameters.Add(new SqliteParameter("$from", request.From.Value.ToString("o")));
}
if (request.To.HasValue)
{
whereClauses.Add("timestamp <= $to");
parameters.Add(new SqliteParameter("$to", request.To.Value.ToString("o")));
}
if (!string.IsNullOrWhiteSpace(request.EventType))
{
whereClauses.Add("event_type = $eventType");
parameters.Add(new SqliteParameter("$eventType", request.EventType));
}
if (!string.IsNullOrWhiteSpace(request.Severity))
{
whereClauses.Add("severity = $severity");
parameters.Add(new SqliteParameter("$severity", request.Severity));
}
if (!string.IsNullOrWhiteSpace(request.InstanceId))
{
whereClauses.Add("instance_id = $instanceId");
parameters.Add(new SqliteParameter("$instanceId", request.InstanceId));
}
if (!string.IsNullOrWhiteSpace(request.KeywordFilter))
{
whereClauses.Add("(message LIKE $keyword OR source LIKE $keyword)");
parameters.Add(new SqliteParameter("$keyword", $"%{request.KeywordFilter}%"));
}
var whereClause = whereClauses.Count > 0
? "WHERE " + string.Join(" AND ", whereClauses)
: "";
// Fetch pageSize + 1 to determine if there are more results
cmd.CommandText = $"""
SELECT id, timestamp, event_type, severity, instance_id, source, message, details
FROM site_events
{whereClause}
ORDER BY id ASC
LIMIT $limit
""";
cmd.Parameters.AddWithValue("$limit", pageSize + 1);
foreach (var p in parameters)
cmd.Parameters.Add(p);
var entries = new List<EventLogEntry>();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
entries.Add(new EventLogEntry(
Id: reader.GetInt64(0),
Timestamp: DateTimeOffset.Parse(reader.GetString(1)),
EventType: reader.GetString(2),
Severity: reader.GetString(3),
InstanceId: reader.IsDBNull(4) ? null : reader.GetString(4),
Source: reader.GetString(5),
Message: reader.GetString(6),
Details: reader.IsDBNull(7) ? null : reader.GetString(7)));
}
var hasMore = entries.Count > pageSize;
if (hasMore)
{
entries.RemoveAt(entries.Count - 1);
}
var continuationToken = entries.Count > 0 ? entries[^1].Id : (long?)null;
return new EventLogQueryResponse(
CorrelationId: request.CorrelationId,
SiteId: request.SiteId,
Entries: entries,
ContinuationToken: continuationToken,
HasMore: hasMore,
Success: true,
ErrorMessage: null,
Timestamp: DateTimeOffset.UtcNow);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to execute event log query: {CorrelationId}", request.CorrelationId);
return new EventLogQueryResponse(
CorrelationId: request.CorrelationId,
SiteId: request.SiteId,
Entries: [],
ContinuationToken: null,
HasMore: false,
Success: false,
ErrorMessage: ex.Message,
Timestamp: DateTimeOffset.UtcNow);
}
}
}