424db1555d
#122: the Notification Outbox KPI snapshots ORed every status in one aggregation, which scanned the clustered index once the table held ~1 M Parked rows (0.7-2.6 s; Health dashboard up to 5 s). Each snapshot now runs three status-equality legs that seek their own index (~0.12 s); a cold Health load on the rig is 0.12-0.52 s. #123: site_events event_type/severity indexes are now composites with timestamp, so a filtered page is read in order and stops at the limit (severity=Error over 1 GB: 2,287 -> 15 ms cold, 115 -> 1.6 ms warm). Costs 454 -> 530 B/row, ~14 % fewer rows under the same cap. The schema apply drops the old indexes in place; the cap size model samples timestamp three times and re-scores inside its band with unchanged constants. #124: the set-based AuditLog insert's NOT EXISTS probe scanned the EventId index and concurrent writers deadlocked on S/IX page locks. WITH (FORCESEEK): 4 writers 775 rows/s + 76 deadlocks in 30 s -> 5,214 rows/s + 0. New concurrent-writer regression test fails 4/4 without the hint.
145 lines
6.4 KiB
C#
145 lines
6.4 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
|
|
|
|
/// <summary>
|
|
/// Regression tests: the schema must index the columns the
|
|
/// query service filters on so common queries do not full-scan a 1 GB database.
|
|
/// </summary>
|
|
public class SchemaIndexTests : IDisposable
|
|
{
|
|
private readonly SiteEventLogger _logger;
|
|
private readonly SqliteConnection _verifyConnection;
|
|
private readonly string _dbPath;
|
|
private readonly TestLocalDb _localDb;
|
|
|
|
/// <summary>Initializes a new instance of the <c>SchemaIndexTests</c> class.</summary>
|
|
public SchemaIndexTests()
|
|
{
|
|
_dbPath = Path.Combine(Path.GetTempPath(), $"test_index_{Guid.NewGuid()}.db");
|
|
var options = Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath });
|
|
_localDb = TestLocalDb.Create(_dbPath);
|
|
_logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance, _localDb.Db);
|
|
|
|
_verifyConnection = new SqliteConnection($"Data Source={_dbPath}");
|
|
_verifyConnection.Open();
|
|
}
|
|
|
|
/// <summary>Releases the resources used by this instance.</summary>
|
|
public void Dispose()
|
|
{
|
|
_verifyConnection.Dispose();
|
|
_logger.Dispose();
|
|
_localDb.Dispose();
|
|
TestLocalDb.DeleteFiles(_dbPath);
|
|
}
|
|
|
|
/// <summary>Verifies that schema: has index on severity.</summary>
|
|
[Fact]
|
|
public void Schema_HasIndexOnSeverity()
|
|
{
|
|
using var cmd = _verifyConnection.CreateCommand();
|
|
cmd.CommandText =
|
|
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'site_events'";
|
|
var indexes = new List<string>();
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read()) indexes.Add(reader.GetString(0));
|
|
|
|
Assert.Contains("idx_events_severity_timestamp", indexes);
|
|
}
|
|
|
|
/// <summary>Verifies that severity filtered query, when uses index, not full scan.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SeverityFilteredQuery_UsesIndex_NotFullScan()
|
|
{
|
|
await _logger.LogEventAsync("script", "Error", null, "S", "boom");
|
|
await _logger.LogEventAsync("script", "Info", null, "S", "ok");
|
|
|
|
using var cmd = _verifyConnection.CreateCommand();
|
|
cmd.CommandText =
|
|
"EXPLAIN QUERY PLAN SELECT id FROM site_events WHERE severity = 'Error'";
|
|
var plan = new System.Text.StringBuilder();
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
{
|
|
// The detail column holds the human-readable plan step.
|
|
plan.Append(reader.GetString(reader.GetOrdinal("detail"))).Append('\n');
|
|
}
|
|
|
|
var planText = plan.ToString();
|
|
Assert.Contains("idx_events_severity_timestamp", planText);
|
|
Assert.DoesNotContain("SCAN site_events", planText);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the query service's own page shape — an equality filter, ordered by
|
|
/// (timestamp, id), limited to a page — is answered in index order rather than by
|
|
/// sorting every matching row (#123).
|
|
/// </summary>
|
|
/// <param name="column">The equality-filtered column.</param>
|
|
/// <param name="expectedIndex">The composite index expected to answer it.</param>
|
|
[Theory]
|
|
[InlineData("severity", "idx_events_severity_timestamp")]
|
|
[InlineData("event_type", "idx_events_type_timestamp")]
|
|
public void EqualityFilteredPage_ReadsInTimestampOrder_WithoutSortingEveryMatch(string column, string expectedIndex)
|
|
{
|
|
foreach (var window in new[] { "", "AND timestamp >= '2026-01-01' " })
|
|
{
|
|
using var cmd = _verifyConnection.CreateCommand();
|
|
cmd.CommandText =
|
|
$"EXPLAIN QUERY PLAN SELECT id FROM site_events WHERE {column} = 'x' {window}" +
|
|
"ORDER BY timestamp ASC, id ASC LIMIT 501";
|
|
var plan = new System.Text.StringBuilder();
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
plan.Append(reader.GetString(reader.GetOrdinal("detail"))).Append('\n');
|
|
|
|
var planText = plan.ToString();
|
|
Assert.Contains(expectedIndex, planText);
|
|
// "USE TEMP B-TREE FOR LAST TERM OF ORDER BY" (ties on timestamp, broken by id)
|
|
// is fine; a whole-ORDER-BY temp b-tree means every match is read and sorted.
|
|
Assert.DoesNotContain("USE TEMP B-TREE FOR ORDER BY", planText);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that a pre-#123 file loses its single-column type/severity indexes on the next schema apply.</summary>
|
|
[Fact]
|
|
public void Apply_OnAPre123File_ReplacesTheSingleColumnIndexes()
|
|
{
|
|
using var connection = new SqliteConnection("Data Source=:memory:");
|
|
connection.Open();
|
|
using (var cmd = connection.CreateCommand())
|
|
{
|
|
cmd.CommandText = """
|
|
CREATE TABLE site_events (id TEXT NOT NULL PRIMARY KEY, timestamp TEXT NOT NULL,
|
|
event_type TEXT NOT NULL, severity TEXT NOT NULL, instance_id TEXT,
|
|
source TEXT NOT NULL, message TEXT NOT NULL, details TEXT);
|
|
CREATE INDEX idx_events_timestamp ON site_events(timestamp);
|
|
CREATE INDEX idx_events_type ON site_events(event_type);
|
|
CREATE INDEX idx_events_instance ON site_events(instance_id);
|
|
CREATE INDEX idx_events_severity ON site_events(severity);
|
|
INSERT INTO site_events VALUES ('a', '2026-01-01T00:00:00.0000000+00:00', 'alarm', 'Info', NULL, 's', 'm', NULL);
|
|
""";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
SiteEventLogSchema.Apply(connection);
|
|
|
|
using var list = connection.CreateCommand();
|
|
list.CommandText = "SELECT name FROM pragma_index_list('site_events') WHERE origin = 'c' ORDER BY name";
|
|
var indexes = new List<string>();
|
|
using (var reader = list.ExecuteReader())
|
|
while (reader.Read()) indexes.Add(reader.GetString(0));
|
|
|
|
Assert.Equal(
|
|
["idx_events_instance", "idx_events_severity_timestamp", "idx_events_timestamp", "idx_events_type_timestamp"],
|
|
indexes);
|
|
using var count = connection.CreateCommand();
|
|
count.CommandText = "SELECT COUNT(*) FROM site_events INDEXED BY idx_events_severity_timestamp WHERE severity = 'Info'";
|
|
Assert.Equal(1L, (long)count.ExecuteScalar()!);
|
|
}
|
|
}
|