merge(agent-b): rows 81+84 — storage cap now measures site_events' own footprint (sampled page-layout model; dbstat probed unavailable and pinned); backlog note once per tick
This commit is contained in:
@@ -547,6 +547,47 @@ public class EventLogPurgeServiceTests : IDisposable
|
||||
Assert.Equal(LogLevel.Information, note.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunPurge_WhenBothPhasesDeleteRows_LogsTheBacklogNoteOnceForTheWholeTick()
|
||||
{
|
||||
// Register row 84. The note used to be emitted by each phase, so a tick in which the
|
||||
// retention purge AND the cap trim both deleted rows produced two identical Information
|
||||
// lines. It is now emitted once, from the tick's shared delete budget — which also makes
|
||||
// the number it reports the figure an operator actually wants next to an oplog spike:
|
||||
// the whole tick's contribution, not one phase's.
|
||||
const int expiredRows = 50;
|
||||
const int recentRows = 50;
|
||||
for (int i = 0; i < expiredRows; i++)
|
||||
{
|
||||
InsertEventWithTimestamp(DateTimeOffset.UtcNow.AddDays(-31));
|
||||
}
|
||||
for (int i = 0; i < recentRows; i++)
|
||||
{
|
||||
InsertEventWithTimestamp(DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
var capOptions = new SiteEventLogOptions
|
||||
{
|
||||
DatabasePath = _dbPath,
|
||||
RetentionDays = 30,
|
||||
MaxStorageMb = 0 // retention takes the expired rows; the cap trim takes the rest
|
||||
};
|
||||
|
||||
var logger = new CapturingLogger();
|
||||
var purge = CreatePurgeService(capOptions, isReplicationConfigured: () => true, logger: logger);
|
||||
purge.RunPurge();
|
||||
|
||||
// Both phases really did delete — otherwise this would pass for the wrong reason.
|
||||
Assert.Contains(logger.Entries, e =>
|
||||
e.Message.Contains($"Purged {expiredRows} events older than", StringComparison.Ordinal));
|
||||
Assert.Contains(logger.Entries, e => e.Message.Contains("over the 0 MB cap", StringComparison.Ordinal));
|
||||
Assert.Equal(0, GetEventCount());
|
||||
|
||||
var note = Assert.Single(logger.Entries, e => e.Message.Contains(BacklogNoteFragment, StringComparison.Ordinal));
|
||||
Assert.Equal(LogLevel.Information, note.Level);
|
||||
Assert.Contains($"Purged {expiredRows + recentRows} site_events rows", note.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunPurge_WhenReplicationCheckThrows_StillPurgesAndSwallows()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Register row 81 — the storage cap measures <c>site_events</c>' own pages, not the whole
|
||||
/// consolidated LocalDb file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The defect these tests close: <c>site_events</c> shares one SQLite file with nine other
|
||||
/// tables plus LocalDb's <c>__localdb_oplog</c> / <c>__localdb_row_version</c> bookkeeping, and
|
||||
/// the cap used to compare <c>MaxStorageMb</c> against <c>page_count - freelist_count</c> over
|
||||
/// that whole file. On a replication-enabled node the trim therefore fought itself — each
|
||||
/// 1000-row delete freed ~390 KB of event pages while adding ~1000 oplog rows and ~1000
|
||||
/// tombstones to the same file — and stopped 2.5x over a 64 MB cap with 226 k rows still
|
||||
/// present (<c>docs/plans/2026-08-15-site-events-purge-burst-measurement.md</c> §5).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A real replication pair is not needed to pin the semantics: what matters is that OTHER pages
|
||||
/// in the same file neither consume nor block the event log's budget. A plain ballast table
|
||||
/// stands in for the oplog, tombstones and the other nine tables alike.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class EventLogStorageCapScopeTests : IDisposable
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
private readonly SiteEventLogger _eventLogger;
|
||||
private readonly TestLocalDb _localDb;
|
||||
private readonly string _dbPath;
|
||||
|
||||
public EventLogStorageCapScopeTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"test_capscope_{Guid.NewGuid()}.db");
|
||||
_localDb = TestLocalDb.Create(_dbPath);
|
||||
_eventLogger = new SiteEventLogger(
|
||||
Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath }),
|
||||
NullLogger<SiteEventLogger>.Instance,
|
||||
_localDb.Db);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_eventLogger.Dispose();
|
||||
_localDb.Dispose();
|
||||
TestLocalDb.DeleteFiles(_dbPath);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private EventLogPurgeService CreatePurgeService(SiteEventLogOptions options, ILogger<EventLogPurgeService>? logger = null) =>
|
||||
new(_eventLogger, Options.Create(options), logger ?? NullLogger<EventLogPurgeService>.Instance);
|
||||
|
||||
private SiteEventLogOptions CapOptions(int maxStorageMb) => new()
|
||||
{
|
||||
DatabasePath = _dbPath,
|
||||
// Far enough in the past that the retention purge never fires — these tests are about
|
||||
// the cap trim in isolation, and a shared tick budget would otherwise blur the two.
|
||||
RetentionDays = 3650,
|
||||
MaxStorageMb = maxStorageMb,
|
||||
};
|
||||
|
||||
/// <summary>Seeds <paramref name="count"/> events, each ~256 bytes of payload.</summary>
|
||||
private void SeedEvents(int count)
|
||||
{
|
||||
var details = new string('x', 200);
|
||||
_eventLogger.WithConnection(connection =>
|
||||
{
|
||||
using var tx = connection.BeginTransaction();
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = """
|
||||
INSERT INTO site_events (id, timestamp, event_type, severity, instance_id, source, message, details)
|
||||
VALUES ($id, $ts, 'script', 'Info', $inst, 'SiteRuntime', 'Bulk event', $details)
|
||||
""";
|
||||
var id = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
var ts = cmd.Parameters.Add("$ts", SqliteType.Text);
|
||||
var inst = cmd.Parameters.Add("$inst", SqliteType.Text);
|
||||
var det = cmd.Parameters.Add("$details", SqliteType.Text);
|
||||
var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
id.Value = Guid.NewGuid().ToString("N");
|
||||
ts.Value = start.AddSeconds(i).ToString("o");
|
||||
inst.Value = i % 3 == 0 ? DBNull.Value : Guid.NewGuid().ToString();
|
||||
det.Value = details;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
tx.Commit();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills a non-event table in the SAME file, standing in for LocalDb's oplog and
|
||||
/// row-version tables and the nine other consolidated tables.
|
||||
/// </summary>
|
||||
private void SeedBallast(int rows, int bytesPerRow)
|
||||
{
|
||||
var payload = new string('b', bytesPerRow);
|
||||
_eventLogger.WithConnection(connection =>
|
||||
{
|
||||
using var create = connection.CreateCommand();
|
||||
create.CommandText = "CREATE TABLE IF NOT EXISTS test_ballast (seq INTEGER PRIMARY KEY, row_json TEXT)";
|
||||
create.ExecuteNonQuery();
|
||||
|
||||
using var tx = connection.BeginTransaction();
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = "INSERT INTO test_ballast (row_json) VALUES ($p)";
|
||||
var p = cmd.Parameters.Add("$p", SqliteType.Text);
|
||||
for (var i = 0; i < rows; i++)
|
||||
{
|
||||
p.Value = payload;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
tx.Commit();
|
||||
});
|
||||
}
|
||||
|
||||
private long EventCount() => _eventLogger.WithConnection(connection =>
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM site_events";
|
||||
return (long)cmd.ExecuteScalar()!;
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void EstimatedSize_ExcludesOtherTablesInTheSameFile()
|
||||
{
|
||||
// A small ballast is seeded up front so the whole-file clamp inside the estimator is
|
||||
// slack in BOTH readings — otherwise the first would be pinned to the file size and the
|
||||
// comparison would be measuring the clamp rather than the model.
|
||||
SeedBallast(rows: 4_000, bytesPerRow: 400);
|
||||
SeedEvents(4000);
|
||||
|
||||
var purge = CreatePurgeService(CapOptions(1024));
|
||||
var eventsOnly = purge.EstimateSiteEventsSize().EstimatedBytes;
|
||||
var fileBefore = purge.GetDatabaseSizeBytes();
|
||||
|
||||
SeedBallast(rows: 40_000, bytesPerRow: 500);
|
||||
|
||||
var eventsAfterBallast = purge.EstimateSiteEventsSize().EstimatedBytes;
|
||||
var fileAfter = purge.GetDatabaseSizeBytes();
|
||||
|
||||
_output.WriteLine($"site_events estimate {eventsOnly} -> {eventsAfterBallast}; file {fileBefore} -> {fileAfter}");
|
||||
|
||||
// The file grew several-fold; the event log's own measured footprint did not move at all.
|
||||
Assert.True(fileAfter > fileBefore * 2,
|
||||
$"Ballast must visibly grow the file ({fileBefore} -> {fileAfter}).");
|
||||
Assert.Equal(eventsOnly, eventsAfterBallast);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PurgeByStorageCap_WithLargeBallastInTheSameFile_DoesNotTrimAnUnderCapEventLog()
|
||||
{
|
||||
// The most direct expression of row 81: on a replicated node with an undrained oplog the
|
||||
// file dwarfs site_events. Under the old whole-file cap the trim would run — and delete
|
||||
// events — purely because of bytes the event log does not own.
|
||||
SeedEvents(4000);
|
||||
|
||||
var sizingProbe = CreatePurgeService(CapOptions(1024));
|
||||
var eventBytes = sizingProbe.EstimateSiteEventsSize().EstimatedBytes;
|
||||
|
||||
SeedBallast(rows: 40_000, bytesPerRow: 500);
|
||||
|
||||
// Cap comfortably above site_events but far BELOW the whole file.
|
||||
var capMb = (int)Math.Max(1, (eventBytes * 2) / (1024 * 1024));
|
||||
var purge = CreatePurgeService(CapOptions(capMb));
|
||||
|
||||
Assert.True(purge.GetDatabaseSizeBytes() > (long)capMb * 1024 * 1024,
|
||||
"Precondition: the whole file must exceed the cap, so a file-scoped cap would trim.");
|
||||
|
||||
purge.RunPurge();
|
||||
|
||||
Assert.Equal(4000, EventCount());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PurgeByStorageCap_WithLargeBallastInTheSameFile_StillEnforcesTheCapOnEventsAndDoesNotEmptyTheTable()
|
||||
{
|
||||
// The other half: the cap must still be MET. Under the old whole-file measurement this
|
||||
// was impossible with unreclaimable ballast present — the trim either emptied the table
|
||||
// chasing a file size it could not reach, or (as measured) tripped the progress guard
|
||||
// and left the log 2.5x over cap.
|
||||
SeedEvents(6000);
|
||||
SeedBallast(rows: 40_000, bytesPerRow: 500);
|
||||
|
||||
var sizingProbe = CreatePurgeService(CapOptions(1024));
|
||||
var eventBytes = sizingProbe.EstimateSiteEventsSize().EstimatedBytes;
|
||||
|
||||
var capBytes = eventBytes / 2;
|
||||
var capMb = (int)Math.Max(1, capBytes / (1024 * 1024));
|
||||
var purge = CreatePurgeService(CapOptions(capMb));
|
||||
|
||||
purge.RunPurge();
|
||||
|
||||
var remaining = EventCount();
|
||||
var finalEstimate = purge.EstimateSiteEventsSize().EstimatedBytes;
|
||||
var finalCapBytes = (long)capMb * 1024 * 1024;
|
||||
|
||||
_output.WriteLine($"remaining={remaining} estimate={finalEstimate} cap={finalCapBytes} file={purge.GetDatabaseSizeBytes()}");
|
||||
|
||||
Assert.True(remaining > 0, "The cap trim must not empty the event table.");
|
||||
Assert.True(remaining < 6000, "The cap trim must remove events when the event log is over cap.");
|
||||
Assert.True(finalEstimate <= finalCapBytes,
|
||||
$"site_events estimate {finalEstimate} must be at or below the cap {finalCapBytes}.");
|
||||
|
||||
// And the proof that the cap is table-scoped: the FILE is still way over it, and that is
|
||||
// correct — those bytes belong to LocalDb bookkeeping and the other consolidated tables.
|
||||
Assert.True(purge.GetDatabaseSizeBytes() > finalCapBytes,
|
||||
"The whole file is expected to stay over the cap — the cap no longer measures it.");
|
||||
}
|
||||
|
||||
// ── size-model accuracy ──
|
||||
|
||||
public static TheoryData<string, int, int, double> Workloads => new()
|
||||
{
|
||||
// name, rows, details length (0 = no details column), details on every row
|
||||
{ "narrow rows, no details", 8000, 0, 1.0 },
|
||||
{ "typical rows, 200B details", 6000, 200, 1.0 },
|
||||
{ "wide rows, 2KB details", 3000, 2048, 1.0 },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Workloads))]
|
||||
public void EstimateSiteEventsSize_TracksTheRealFootprint(string name, int rows, int detailsLength, double detailsFraction)
|
||||
{
|
||||
// Calibration guard for the modelled estimate. site_events' true page count is obtained
|
||||
// DIFFERENTIALLY: the file is measured with ballast only, the events are then added, and
|
||||
// the growth is what the table and its four indexes actually cost. Seeding the ballast
|
||||
// first also keeps the estimator's whole-file clamp slack, so this scores the model
|
||||
// itself rather than the clamp.
|
||||
//
|
||||
// The band is deliberately asymmetric. The model is tuned to run somewhat high, because
|
||||
// a cap that fires early wastes a slice of the budget whereas one that reads low lets
|
||||
// the log overrun the disk allowance the cap exists to defend.
|
||||
SeedBallast(rows: 4_000, bytesPerRow: 400);
|
||||
|
||||
var purge = CreatePurgeService(CapOptions(1024));
|
||||
var baselineBytes = purge.GetDatabaseSizeBytes();
|
||||
|
||||
var details = detailsLength > 0 ? new string('d', detailsLength) : null;
|
||||
_eventLogger.WithConnection(connection =>
|
||||
{
|
||||
using var tx = connection.BeginTransaction();
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = """
|
||||
INSERT INTO site_events (id, timestamp, event_type, severity, instance_id, source, message, details)
|
||||
VALUES ($id, $ts, 'ScriptExecution', 'Information', $inst, 'SiteRuntime', $msg, $details)
|
||||
""";
|
||||
var id = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
var ts = cmd.Parameters.Add("$ts", SqliteType.Text);
|
||||
var inst = cmd.Parameters.Add("$inst", SqliteType.Text);
|
||||
var msg = cmd.Parameters.Add("$msg", SqliteType.Text);
|
||||
var det = cmd.Parameters.Add("$details", SqliteType.Text);
|
||||
var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var rnd = new Random(7);
|
||||
for (var i = 0; i < rows; i++)
|
||||
{
|
||||
id.Value = Guid.NewGuid().ToString();
|
||||
ts.Value = start.AddSeconds(i).ToString("o");
|
||||
inst.Value = i % 3 == 0 ? DBNull.Value : Guid.NewGuid().ToString();
|
||||
msg.Value = $"Script 'Recipe{i % 50}' completed in {rnd.Next(1, 900)} ms";
|
||||
det.Value = details is not null && rnd.NextDouble() < detailsFraction ? details : (object)DBNull.Value;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
tx.Commit();
|
||||
});
|
||||
|
||||
var estimated = purge.EstimateSiteEventsSize().EstimatedBytes;
|
||||
var actual = purge.GetDatabaseSizeBytes() - baselineBytes;
|
||||
var error = (estimated - (double)actual) / actual;
|
||||
|
||||
_output.WriteLine($"{name}: rows={rows} estimated={estimated} actual={actual} error={error:P1}");
|
||||
|
||||
Assert.InRange(error, -0.05, 0.25);
|
||||
}
|
||||
|
||||
// ── wipe-prevention backstops ──
|
||||
|
||||
/// <summary>Purge service whose size estimate and row count can be pinned by a test.</summary>
|
||||
private sealed class StubbedEstimatePurgeService(
|
||||
SiteEventLogger eventLogger,
|
||||
IOptions<SiteEventLogOptions> options,
|
||||
ILogger<EventLogPurgeService> logger,
|
||||
EventLogPurgeService.SiteEventsSizeEstimate? estimate,
|
||||
long? frozenRowCount)
|
||||
: EventLogPurgeService(eventLogger, options, logger)
|
||||
{
|
||||
internal override EventLogPurgeService.SiteEventsSizeEstimate EstimateSiteEventsSize() =>
|
||||
estimate ?? base.EstimateSiteEventsSize();
|
||||
|
||||
internal override long GetSiteEventsRowCount() =>
|
||||
frozenRowCount ?? base.GetSiteEventsRowCount();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PurgeByStorageCap_WhenRowWidthCannotBeSampled_DeletesNothingAndWarns()
|
||||
{
|
||||
// First backstop. A zero row width makes "how many rows put us under cap" unanswerable.
|
||||
// The old loop's answer to an unanswerable cap was to keep deleting until the file
|
||||
// shrank, which is precisely how a cap that can never be met empties a table.
|
||||
SeedEvents(2000);
|
||||
|
||||
var logger = new CapturingLogger();
|
||||
var purge = new StubbedEstimatePurgeService(
|
||||
_eventLogger,
|
||||
Options.Create(CapOptions(0)),
|
||||
logger,
|
||||
new EventLogPurgeService.SiteEventsSizeEstimate(RowCount: 2000, BytesPerRow: 0),
|
||||
frozenRowCount: null);
|
||||
|
||||
purge.RunPurge();
|
||||
|
||||
Assert.Equal(2000, EventCount());
|
||||
Assert.Contains(logger.Entries, e =>
|
||||
e.Level == LogLevel.Warning && e.Message.Contains("row width could not be sampled", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PurgeByStorageCap_WhenTheTableStopsShrinking_StopsRatherThanEmptyingTheLog()
|
||||
{
|
||||
// Second backstop, carried over from the old "size did not decrease" guard but re-based
|
||||
// onto the table-scoped metric. It can no longer be tripped by replication bookkeeping;
|
||||
// a frozen row count is what it now models — writers arriving as fast as the trim deletes.
|
||||
SeedEvents(5000);
|
||||
|
||||
var logger = new CapturingLogger();
|
||||
var purge = new StubbedEstimatePurgeService(
|
||||
_eventLogger,
|
||||
Options.Create(CapOptions(0)), // a 0 MB cap would otherwise delete every row
|
||||
logger,
|
||||
estimate: null,
|
||||
frozenRowCount: 5000);
|
||||
|
||||
purge.RunPurge();
|
||||
|
||||
var remaining = EventCount();
|
||||
Assert.True(remaining > 0, "The guard must stop the trim before the table is emptied.");
|
||||
Assert.True(remaining >= 4000, $"Only one batch should have been deleted; {5000 - remaining} rows went.");
|
||||
Assert.Contains(logger.Entries, e =>
|
||||
e.Level == LogLevel.Warning && e.Message.Contains("did not decrease", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private sealed record LogEntry(LogLevel Level, string Message);
|
||||
|
||||
private sealed class CapturingLogger : ILogger<EventLogPurgeService>
|
||||
{
|
||||
public List<LogEntry> Entries { get; } = [];
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter) =>
|
||||
Entries.Add(new LogEntry(logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the two SQLite provider capabilities the storage cap's size model depends on
|
||||
/// (register row 81, <c>docs/plans/2026-08-17-site-event-log-cap-semantics-decision.md</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The intended fix for row 81 was to measure <c>site_events</c>' own pages exactly through the
|
||||
/// <c>dbstat</c> virtual table. These tests are the evidence that this is not possible with the
|
||||
/// native SQLite this solution ships, and the tripwire that will tell us if that ever changes —
|
||||
/// if <c>dbstat</c> appears, the modelled estimate in
|
||||
/// <see cref="EventLogPurgeService.EstimateSiteEventsSize"/> should be replaced by a real
|
||||
/// measurement.
|
||||
/// </remarks>
|
||||
public class SqliteCapabilityTests
|
||||
{
|
||||
private static SqliteConnection OpenMemory()
|
||||
{
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
connection.Open();
|
||||
return connection;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DbStat_IsNotAvailable_SoThePerTablePageCountMustBeModelled()
|
||||
{
|
||||
// If this test starts FAILING, that is good news, not a regression: the bundled
|
||||
// e_sqlite3 has gained SQLITE_ENABLE_DBSTAT_VTAB and the cap can measure
|
||||
// `SELECT sum(pgsize) FROM dbstat WHERE name='site_events'` exactly instead of
|
||||
// modelling it. Delete the model, not this expectation, if that day comes.
|
||||
using var connection = OpenMemory();
|
||||
|
||||
using var compileOptions = connection.CreateCommand();
|
||||
compileOptions.CommandText = "PRAGMA compile_options";
|
||||
var options = new List<string>();
|
||||
using (var reader = compileOptions.ExecuteReader())
|
||||
{
|
||||
while (reader.Read())
|
||||
options.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
Assert.DoesNotContain("ENABLE_DBSTAT_VTAB", options);
|
||||
|
||||
using var modules = connection.CreateCommand();
|
||||
modules.CommandText = "SELECT name FROM pragma_module_list";
|
||||
var names = new List<string>();
|
||||
using (var reader = modules.ExecuteReader())
|
||||
{
|
||||
while (reader.Read())
|
||||
names.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
// The list is non-empty (fts3/4/5, rtree, geopoly …) so an empty result is not being
|
||||
// mistaken for "no dbstat".
|
||||
Assert.NotEmpty(names);
|
||||
Assert.DoesNotContain("dbstat", names);
|
||||
Assert.DoesNotContain("sqlite_dbpage", names);
|
||||
|
||||
using var query = connection.CreateCommand();
|
||||
query.CommandText = "SELECT sum(pgsize) FROM dbstat";
|
||||
var ex = Assert.Throws<SqliteException>(() => query.ExecuteScalar());
|
||||
Assert.Contains("dbstat", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OctetLength_IsAvailable_SoRowWidthIsSampledInBytesNotCharacters()
|
||||
{
|
||||
// The size model samples row width with octet_length() (SQLite 3.43+). length() counts
|
||||
// CHARACTERS, so a multi-byte UTF-8 message would be under-sized and the cap enforced
|
||||
// late. A provider downgrade that drops octet_length must fail here rather than
|
||||
// silently shrinking every estimate at runtime.
|
||||
using var connection = OpenMemory();
|
||||
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT octet_length('skål'), length('skål')";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
|
||||
Assert.True(reader.Read());
|
||||
Assert.Equal(5, reader.GetInt32(0)); // 'å' is two bytes in UTF-8
|
||||
Assert.Equal(4, reader.GetInt32(1));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user