11675285af
P4-5: EventLogPurgeService.ExecuteAsync now yields (Task.Yield()) before running the initial RunPurge(), making "the purge never runs synchronously on the caller's thread" a property of the method itself rather than an artifact of .NET 8+'s BackgroundService scheduling (verified empirically not to reproduce the literal "StartAsync blocks" symptom on this repo's net10.0 target — a 1.5s synchronous Thread.Sleep in ExecuteAsync still lets StartAsync return in ~2ms). Proven instead via a reflection-invoked ExecuteAsync test that bypasses BackgroundService's Task.Run wrapper, which is timing-independent. Also: a throwing isActive predicate now fails CLOSED (logged as inactive, at Warning) instead of silently claiming "active" — the predicate only affects the tick's log line since P4-4, but a throw must not masquerade as a successful check. P4-6: PRAGMA auto_vacuum = INCREMENTAL only takes effect on a table-less database, and LocalDbSchema.EnsureCreated (inside SqliteLocalDb's constructor) always creates LocalDb's own bookkeeping tables before SiteEventLogSchema.Apply ever gets a connection — so the pragma there had never once taken effect, in any real call path. Real fix per plan decision 2: SiteLocalDbSetup.EnsureAutoVacuumOnFreshFile pre-creates a genuinely fresh LocalDb file with the pragma set, called from SiteServiceRegistration.Configure BEFORE AddZbLocalDb's lazy singleton ever constructs SqliteLocalDb — no LocalDb API change needed. Only helps NEW site databases; an existing file (every node on the docker rig today) is left untouched rather than VACUUM'd, per the plan's explicit rejection of an unconditional VACUUM against a live ~1 GB file. The now-dead pragma call in SiteEventLogSchema.Apply is removed and its comment corrected. Tests proven to fail against the unfixed code (git-diff-and-restore, not stash — this repo shares one stash ref across all worktrees): ExecuteAsync_YieldsBeforeRunningTheInitial- PurgeSynchronously and RunPurge_ActiveNodeCheckThrows_TickLogsAsInactive_NotActive fail without the EventLogPurgeService fix; SiteLocalDbAutoVacuumTests fails to compile without EnsureAutoVacuumOnFreshFile. SiteEventLogging.Tests 112/112 and Host.Tests 526/526 green with the fix applied.
172 lines
7.0 KiB
C#
172 lines
7.0 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ZB.MOM.WW.LocalDb;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
|
|
|
/// <summary>
|
|
/// Arch-review 2026-08-19 P4-6 — <c>PRAGMA auto_vacuum = INCREMENTAL</c> only takes effect on
|
|
/// a table-less database, and <c>SqliteLocalDb</c>'s constructor creates LocalDb's own
|
|
/// bookkeeping tables before anything else ever gets a connection to the consolidated site
|
|
/// file. <see cref="SiteLocalDbSetup.EnsureAutoVacuumOnFreshFile"/> is the fix: it pre-creates
|
|
/// a genuinely fresh file with the pragma set BEFORE <c>AddZbLocalDb</c>'s lazy singleton ever
|
|
/// constructs <c>SqliteLocalDb</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// These tests exercise <see cref="SiteLocalDbSetup.EnsureAutoVacuumOnFreshFile"/> directly
|
|
/// (it is <c>internal</c>, visible here via <c>InternalsVisibleTo</c>) rather than through the
|
|
/// full <see cref="SiteServiceRegistration"/> composition root — the mechanism under test is a
|
|
/// pure ordering/pragma concern that does not depend on the rest of the site graph, and
|
|
/// <see cref="SiteLocalDbWiringTests"/> already proves the composition root wires
|
|
/// <c>SiteLocalDbSetup</c> in at all.
|
|
/// </remarks>
|
|
public class SiteLocalDbAutoVacuumTests
|
|
{
|
|
[Fact]
|
|
public void FreshFile_ReportsIncrementalAutoVacuum()
|
|
{
|
|
// The plan's own acceptance test: a fresh file reports PRAGMA auto_vacuum = 2
|
|
// (INCREMENTAL) after the pre-create step runs.
|
|
var path = TempDbPath();
|
|
try
|
|
{
|
|
var config = ConfigFor(path);
|
|
|
|
SiteLocalDbSetup.EnsureAutoVacuumOnFreshFile(config);
|
|
|
|
Assert.Equal(2L, ReadAutoVacuum(path));
|
|
}
|
|
finally
|
|
{
|
|
Cleanup(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void FreshFile_AutoVacuumSurvives_LocalDbCreatingItsOwnBookkeepingTablesAfterward()
|
|
{
|
|
// The real end-to-end claim: the pre-create step has to run BEFORE AddZbLocalDb ever
|
|
// constructs SqliteLocalDb (which creates LocalDb's own tables first, unconditionally,
|
|
// in its constructor) for the ordering fix to mean anything. This proves the flag
|
|
// baked in by EnsureAutoVacuumOnFreshFile survives that later table creation — the
|
|
// exact sequence SiteServiceRegistration.Configure now runs in production.
|
|
var path = TempDbPath();
|
|
try
|
|
{
|
|
var config = ConfigFor(path);
|
|
|
|
SiteLocalDbSetup.EnsureAutoVacuumOnFreshFile(config);
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddZbLocalDb(config, db => SiteLocalDbSetup.OnReady(db, config));
|
|
using var provider = services.BuildServiceProvider();
|
|
using var scope = provider.CreateScope();
|
|
|
|
// Resolving ILocalDb is what triggers SqliteLocalDb's constructor (LocalDbSchema
|
|
// .EnsureCreated) and then OnReady (SiteEventLogSchema.Apply, etc.) — i.e. every
|
|
// real table this file will ever hold. This config carries no
|
|
// LocalDb:Replication section, so OnReady takes the deregistration branch and
|
|
// db.ReplicatedTables stays empty — check the raw file instead to confirm the
|
|
// table genuinely exists.
|
|
var db = scope.ServiceProvider.GetRequiredService<ILocalDb>();
|
|
Assert.NotNull(db);
|
|
Assert.Contains("site_events", TableNames(path));
|
|
|
|
Assert.Equal(2L, ReadAutoVacuum(path));
|
|
}
|
|
finally
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
Cleanup(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ExistingFile_IsLeftAlone_AutoVacuumStaysNone()
|
|
{
|
|
// Honesty requirement (plan decision 2): the fix only helps a file that does not exist
|
|
// yet. A file that already has tables — every node on the docker rig today, and any
|
|
// site database created before this fix shipped — is untouched, and stays
|
|
// auto_vacuum = NONE forever short of an offline VACUUM this task deliberately does
|
|
// not attempt.
|
|
var path = TempDbPath();
|
|
try
|
|
{
|
|
// Pre-populate the file exactly like a real (pre-fix) LocalDb file: at least one
|
|
// table exists before EnsureAutoVacuumOnFreshFile ever runs.
|
|
using (var seed = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path }.ToString()))
|
|
{
|
|
seed.Open();
|
|
using var cmd = seed.CreateCommand();
|
|
cmd.CommandText = "CREATE TABLE legacy_marker (id INTEGER PRIMARY KEY)";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
Assert.Equal(0L, ReadAutoVacuum(path)); // NONE, the SQLite default.
|
|
|
|
var config = ConfigFor(path);
|
|
SiteLocalDbSetup.EnsureAutoVacuumOnFreshFile(config);
|
|
|
|
// Still NONE — the method must not attempt VACUUM against a populated file.
|
|
Assert.Equal(0L, ReadAutoVacuum(path));
|
|
}
|
|
finally
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
Cleanup(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingLocalDbPath_DoesNotThrow()
|
|
{
|
|
// AddZbLocalDb's own options validation is the authoritative place a missing path is
|
|
// reported; this pre-create step must defer to it rather than throwing its own,
|
|
// differently-worded error first.
|
|
var config = new ConfigurationBuilder().AddInMemoryCollection(
|
|
new Dictionary<string, string?>()).Build();
|
|
|
|
var ex = Record.Exception(() => SiteLocalDbSetup.EnsureAutoVacuumOnFreshFile(config));
|
|
|
|
Assert.Null(ex);
|
|
}
|
|
|
|
private static string TempDbPath() =>
|
|
Path.Combine(Path.GetTempPath(), $"scadabridge_autovacuum_{Guid.NewGuid():N}.db");
|
|
|
|
private static IConfiguration ConfigFor(string path) =>
|
|
new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = path })
|
|
.Build();
|
|
|
|
private static long ReadAutoVacuum(string path)
|
|
{
|
|
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path }.ToString());
|
|
connection.Open();
|
|
using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = "PRAGMA auto_vacuum";
|
|
return Convert.ToInt64(cmd.ExecuteScalar());
|
|
}
|
|
|
|
private static HashSet<string> TableNames(string path)
|
|
{
|
|
using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path }.ToString());
|
|
connection.Open();
|
|
using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table'";
|
|
using var reader = cmd.ExecuteReader();
|
|
var names = new HashSet<string>(StringComparer.Ordinal);
|
|
while (reader.Read()) names.Add(reader.GetString(0));
|
|
return names;
|
|
}
|
|
|
|
private static void Cleanup(string path)
|
|
{
|
|
foreach (var suffix in new[] { "", "-wal", "-shm" })
|
|
{
|
|
try { File.Delete(path + suffix); } catch { /* best effort */ }
|
|
}
|
|
}
|
|
}
|