chore(deps): LocalDb 0.2.0 — dereg cleanup, late-opt-in baselining, byte-budget replication

This commit is contained in:
Joseph Doherty
2026-08-14 22:22:18 -04:00
parent 312216ff2b
commit cca7f1786d
9 changed files with 288 additions and 69 deletions
@@ -233,6 +233,12 @@ public class SiteHealthCheckTests : IDisposable
public ReplicatedTable RegisterReplicated(string tableName) =>
throw new InvalidOperationException("store unreachable");
public ReplicatedTable RegisterReplicated(string tableName, bool baselineExistingRows) =>
throw new InvalidOperationException("store unreachable");
public bool DeregisterReplicated(string tableName) =>
throw new InvalidOperationException("store unreachable");
public IReadOnlyDictionary<string, ReplicatedTable> ReplicatedTables =>
throw new InvalidOperationException("store unreachable");
}
@@ -104,6 +104,81 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable
Assert.DoesNotContain(triggers, t => t.StartsWith("__localdb_smtp_configurations_", StringComparison.Ordinal));
}
[Fact]
public void UnreplicatedNode_DropsTriggersLeftByAPreviouslyReplicatedBuild()
{
// WP3.3, the residual WP1.3 could only document: skipping registration never removed
// what an EARLIER build installed, so a long-lived unreplicated node upgraded in place
// kept capturing forever. LocalDb 0.2.0's DeregisterReplicated closes it, and this is
// the exact upgrade shape — the same file, booted first WITH replication configured and
// then without. The first boot is what makes the assertion meaningful: without it an
// empty trigger set would prove nothing.
var replicated = BuildDatabase(Config(apiKey: "cdc-test-key"));
AssertCaptureTriggersCoverTheReplicatedTables(replicated);
DisposeProviders();
var unreplicated = BuildDatabase(Config());
Assert.Empty(CaptureTriggers(unreplicated));
}
[Fact]
public void UnreplicatedNode_PrunesTheOplogLeftByAPreviouslyReplicatedBuild()
{
// Deregistration is not only about the trigger cost: the rows already captured are an
// oplog nothing will ever drain, so they are pruned with the triggers. Written through
// the schema's own table so the capture path is the real one.
var replicated = BuildDatabase(Config(apiKey: "cdc-test-key"));
InsertSiteEvent(replicated, "evt-1");
Assert.NotEqual(0, OplogRowCount(replicated));
DisposeProviders();
var unreplicated = BuildDatabase(Config());
Assert.Equal(0, OplogRowCount(unreplicated));
// The table's own data is never touched — deregistration removes bookkeeping, not rows.
Assert.Equal(1, SiteEventCount(unreplicated));
}
[Fact]
public void UnreplicatedNode_OverAFileThatWasNeverRegistered_IsANoOp()
{
// DeregisterReplicated is idempotent and usable on a table this process never
// registered, so the clean-up branch must be harmless on the ordinary case: site-b and
// site-c boot after boot with nothing to clean.
_ = BuildDatabase(Config());
DisposeProviders();
var second = BuildDatabase(Config());
Assert.Empty(CaptureTriggers(second));
foreach (var table in AllSiteTables)
Assert.Contains(table, TableNames(second));
}
[Fact]
public void ReplicatedNode_BaselinesRowsThatPredateRegistration()
{
// The other half of the late-opt-in story: a site that has been running unreplicated
// and then turns replication ON. The snapshot streamer pages from __localdb_row_version,
// which only the capture triggers populate, so without baselining these rows would be
// invisible to the peer forever. RegisterReplicated(..., baselineExistingRows: true)
// seeds them at the LWW floor (HLC 0).
var unreplicated = BuildDatabase(Config());
Assert.Empty(CaptureTriggers(unreplicated));
InsertSiteEvent(unreplicated, "pre-existing");
Assert.Equal(0, RowVersionCount(unreplicated, "site_events"));
DisposeProviders();
var replicated = BuildDatabase(Config(apiKey: "cdc-test-key"));
Assert.Equal(1, RowVersionCount(replicated, "site_events"));
// At the floor, so the seeded version loses to any genuine remote write of the same key.
Assert.Equal(0L, RowVersionHlc(replicated, "site_events"));
}
[Fact]
public void UnreplicatedNode_StillRunsTheLegacyMigrator()
{
@@ -149,6 +224,49 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable
}
}
/// <summary>
/// Closes every database built so far and releases the pooled connections, so the NEXT
/// <see cref="BuildDatabase"/> on the same file opens it fresh — which is what a node
/// restarting with different replication configuration actually does.
/// </summary>
private void DisposeProviders()
{
foreach (var provider in _providers) provider.Dispose();
_providers.Clear();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
}
private static void InsertSiteEvent(ILocalDb db, string id)
{
using var connection = db.CreateConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText =
"""
INSERT INTO site_events (id, timestamp, event_type, severity, source, message)
VALUES ($id, '2026-08-14T00:00:00.0000000Z', 'Test', 'Info', 'cdc-test', 'row');
""";
cmd.Parameters.AddWithValue("$id", id);
cmd.ExecuteNonQuery();
}
private static long OplogRowCount(ILocalDb db) => Scalar(db, "SELECT COUNT(*) FROM __localdb_oplog");
private static long SiteEventCount(ILocalDb db) => Scalar(db, "SELECT COUNT(*) FROM site_events");
private static long RowVersionCount(ILocalDb db, string table) => Scalar(
db, $"SELECT COUNT(*) FROM __localdb_row_version WHERE table_name = '{table}'");
private static long RowVersionHlc(ILocalDb db, string table) => Scalar(
db, $"SELECT MAX(hlc) FROM __localdb_row_version WHERE table_name = '{table}'");
private static long Scalar(ILocalDb db, string sql)
{
using var connection = db.CreateConnection();
using var cmd = connection.CreateCommand();
cmd.CommandText = sql;
return Convert.ToInt64(cmd.ExecuteScalar());
}
private ILocalDb BuildDatabase(IConfiguration config)
{
var provider = new ServiceCollection()