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.
112 lines
5.3 KiB
C#
112 lines
5.3 KiB
C#
using Microsoft.Data.Sqlite;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
|
|
|
|
/// <summary>
|
|
/// Pins the <c>site_events</c> schema to the shape the storage-cap size model was calibrated
|
|
/// against (register row 93, <c>docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md</c> §4).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <see cref="EventLogPurgeService.EstimateSiteEventsSize"/> hardcodes this schema twice over:
|
|
/// the sampling SQL sums <c>octet_length</c> over the exact column list, and
|
|
/// <c>IndexedColumnCount = 4</c> plus the fill-factor/overhead constants are empirical for
|
|
/// exactly these four secondary indexes. A schema migration that adds a column or an index
|
|
/// invalidates both SILENTLY — the new column's bytes are never sampled, a fifth index is
|
|
/// never charged — so this test turns that drift into a loud failure. The fix on failure is
|
|
/// NEVER to just update the assertions: re-run the memo §4 calibration, update the
|
|
/// <see cref="EventLogPurgeService"/> constants and sampling SQL together, and only then
|
|
/// re-pin the schema here.
|
|
/// </remarks>
|
|
public class SiteEventsSchemaCalibrationTests
|
|
{
|
|
private const string RecalibrationNotice =
|
|
"site_events schema drifted from the size-model calibration. Re-run the calibration in " +
|
|
"docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md §4 and update " +
|
|
"EventLogPurgeService's constants + sampling SQL together with this pin (register row 93).";
|
|
|
|
private static SqliteConnection OpenWithSchema()
|
|
{
|
|
var connection = new SqliteConnection("Data Source=:memory:");
|
|
connection.Open();
|
|
SiteEventLogSchema.Apply(connection);
|
|
return connection;
|
|
}
|
|
|
|
private static List<string> ReadStrings(SqliteConnection connection, string sql)
|
|
{
|
|
using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = sql;
|
|
var values = new List<string>();
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
values.Add(reader.GetString(0));
|
|
return values;
|
|
}
|
|
|
|
/// <summary>Verifies that the modelled columns exactly match the sampled octet-length sum.</summary>
|
|
[Fact]
|
|
public void Columns_MatchTheSampledOctetLengthSum_Exactly()
|
|
{
|
|
// The sampler's payload_bytes expression sums octet_length over exactly these eight
|
|
// columns, in this order. A ninth column would contribute real disk bytes that the
|
|
// model never counts, under-sizing every estimate.
|
|
using var connection = OpenWithSchema();
|
|
|
|
var columns = ReadStrings(connection, "SELECT name FROM pragma_table_info('site_events')");
|
|
|
|
Assert.True(
|
|
columns.SequenceEqual(
|
|
["id", "timestamp", "event_type", "severity", "instance_id", "source", "message", "details"]),
|
|
$"{RecalibrationNotice} Actual columns: {string.Join(", ", columns)}");
|
|
}
|
|
|
|
/// <summary>Verifies that the table has exactly the four indexes the model charges for, plus the primary-key autoindex.</summary>
|
|
[Fact]
|
|
public void Indexes_AreExactlyTheFourTheModelCharges_PlusThePkAutoindex()
|
|
{
|
|
// IndexedColumnCount = 4 charges four secondary index entries per row; the TEXT
|
|
// primary key's autoindex is absorbed into the empirically-measured fill factors.
|
|
// A fifth secondary index would be silently under-counted.
|
|
using var connection = OpenWithSchema();
|
|
|
|
var indexes = ReadStrings(
|
|
connection, "SELECT name FROM pragma_index_list('site_events') ORDER BY name");
|
|
|
|
Assert.True(
|
|
indexes.SequenceEqual(
|
|
[
|
|
"idx_events_instance",
|
|
"idx_events_severity_timestamp",
|
|
"idx_events_timestamp",
|
|
"idx_events_type_timestamp",
|
|
"sqlite_autoindex_site_events_1",
|
|
]),
|
|
$"{RecalibrationNotice} Actual indexes: {string.Join(", ", indexes)}");
|
|
}
|
|
|
|
/// <summary>Verifies that each index covers exactly the columns the sampler sums for it.</summary>
|
|
/// <param name="indexName">The index name.</param>
|
|
/// <param name="expectedColumns">The columns the index is expected to cover, comma-separated, in key order.</param>
|
|
[Theory]
|
|
[InlineData("idx_events_timestamp", "timestamp")]
|
|
[InlineData("idx_events_instance", "instance_id")]
|
|
[InlineData("idx_events_type_timestamp", "event_type,timestamp")]
|
|
[InlineData("idx_events_severity_timestamp", "severity,timestamp")]
|
|
public void EachIndex_CoversExactlyTheColumnsTheSamplerSums(string indexName, string expectedColumns)
|
|
{
|
|
// The sampler's indexed_bytes expression sums 3 * octet_length(timestamp) +
|
|
// octet_length(event_type) + octet_length(severity) + octet_length(instance_id):
|
|
// timestamp once for its own index and once for each of the two composites (#123).
|
|
// Widening or re-pointing an index changes the real entry width without changing
|
|
// what is sampled.
|
|
using var connection = OpenWithSchema();
|
|
|
|
var columns = ReadStrings(
|
|
connection, $"SELECT name FROM pragma_index_info('{indexName}') ORDER BY seqno");
|
|
|
|
Assert.True(
|
|
columns.SequenceEqual(expectedColumns.Split(',')),
|
|
$"{RecalibrationNotice} Index {indexName} covers: {string.Join(", ", columns)}");
|
|
}
|
|
}
|