25c15dd164
Row 104: docker/vacuum-site-localdb.sh stops both nodes of a pair together, sets auto_vacuum=INCREMENTAL + VACUUM on each node volume with integrity_check before/after, then starts both (DRY_RUN=1 reports). Run on all three rig pairs: six files 0 -> 2, ok, ~11% smaller; site-a replication reconnected with backlog 0 and no watermark change — VACUUM is replication-invisible (ledger keys by declared PK, oplog seq is INTEGER PRIMARY KEY AUTOINCREMENT). Topology guide gains the recipe. Row 105: SiteLocalDbReplicatedTableFkGuardTests builds the real site schema via OnReady (unreplicated config so 0.3.3's own guard cannot mask the culprit) and asserts every SiteLocalDbSetup.ReplicatedTables entry (now internal) exists and has no inbound CASCADE/SET NULL/SET DEFAULT foreign key, with a negative control. The constraint is enforced at build instead of at the first replicated boot. Row 106 (found during 104's live verification, NOT caused by it — the identical 127k-row full snapshot also landed at the earlier deploy restart): SnapshotStreamer.SendAsync stamps as_of_seq = MAX(seq), which is 0 once a converged sender's oplog is ack-pruned to empty; a zero-watermark receiver (rebuilt node) is never lifted, so the empty-oplog horizon rule re-streams a full snapshot on every reconnect. Sender-side one-line fix for LocalDb 0.3.4: as_of_seq = max(MAX(seq), last_acked_seq).
146 lines
6.2 KiB
C#
146 lines
6.2 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>
|
|
/// Deferred-work register row 105 (arch-review 2026-08-19 P4-7 → LocalDb 0.3.3): a replicated
|
|
/// table must never be the parent of a foreign key with a row-mutating <c>ON DELETE</c> action
|
|
/// (<c>CASCADE</c> / <c>SET NULL</c> / <c>SET DEFAULT</c>). LocalDb's LWW applier upserts with
|
|
/// <c>INSERT OR REPLACE</c> under <c>foreign_keys=ON</c>, so the implicit delete would fire the
|
|
/// child action while change-capture is suppressed — the child rows change on the applying node
|
|
/// only and the pair diverges silently. LocalDb 0.3.3 refuses such a parent at
|
|
/// <c>RegisterReplicated</c> time, which on a site node is a BOOT failure of the replicating
|
|
/// pair. This test moves that discovery from the first replicated boot to the build: it walks
|
|
/// the real site schema (every table <c>SiteLocalDbSetup.OnReady</c> creates) and asserts none
|
|
/// of the ten <see cref="SiteLocalDbSetup.ReplicatedTables"/> has such an inbound FK.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The schema is built with an UNREPLICATED config on purpose: a replicated one would make
|
|
/// 0.3.3's own guard throw first and mask which table/FK is at fault. The checker query mirrors
|
|
/// LocalDb's (<c>sqlite_master JOIN pragma_foreign_key_list</c>), and a negative control proves
|
|
/// it actually finds an offender.
|
|
/// </remarks>
|
|
public class SiteLocalDbReplicatedTableFkGuardTests
|
|
{
|
|
[Fact]
|
|
public void NoReplicatedTable_IsAParentOfARowMutatingOnDeleteForeignKey()
|
|
{
|
|
var path = TempDbPath();
|
|
try
|
|
{
|
|
var config = ConfigFor(path);
|
|
var services = new ServiceCollection();
|
|
services.AddZbLocalDb(config, db => SiteLocalDbSetup.OnReady(db, config));
|
|
using (var provider = services.BuildServiceProvider())
|
|
using (var scope = provider.CreateScope())
|
|
{
|
|
// Resolving ILocalDb runs SqliteLocalDb's constructor and then OnReady — i.e.
|
|
// creates every table the consolidated site file will ever hold.
|
|
Assert.NotNull(scope.ServiceProvider.GetRequiredService<ILocalDb>());
|
|
}
|
|
|
|
var tables = TableNames(path);
|
|
foreach (var table in SiteLocalDbSetup.ReplicatedTables)
|
|
{
|
|
Assert.Contains(table, tables); // the list and the schema must not drift apart
|
|
|
|
var offenders = FindRowMutatingFkChildren(path, table);
|
|
Assert.True(offenders.Count == 0,
|
|
$"Replicated table '{table}' is the parent of a row-mutating ON DELETE foreign key " +
|
|
$"({string.Join(", ", offenders)}). LocalDb 0.3.3 refuses to register it, so a " +
|
|
"replicating site pair would fail to boot — model cross-table cleanup as explicit " +
|
|
"application deletes instead (register row 105).");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
Cleanup(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void NegativeControl_CheckerFindsACascadingChild()
|
|
{
|
|
// The checker must be able to fail, or the test above proves nothing.
|
|
var path = TempDbPath();
|
|
try
|
|
{
|
|
using (var conn = Open(path))
|
|
using (var cmd = conn.CreateCommand())
|
|
{
|
|
cmd.CommandText =
|
|
"CREATE TABLE parent_t (id TEXT PRIMARY KEY);" +
|
|
"CREATE TABLE child_cascade (id INTEGER PRIMARY KEY, parent_id TEXT REFERENCES parent_t(id) ON DELETE CASCADE);" +
|
|
"CREATE TABLE child_restrict (id INTEGER PRIMARY KEY, parent_id TEXT REFERENCES parent_t(id) ON DELETE RESTRICT);";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
var offenders = FindRowMutatingFkChildren(path, "parent_t");
|
|
|
|
var only = Assert.Single(offenders);
|
|
Assert.Equal("child_cascade.parent_id ON DELETE CASCADE", only);
|
|
}
|
|
finally
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
Cleanup(path);
|
|
}
|
|
}
|
|
|
|
/// <summary>Same shape as LocalDb 0.3.3's <c>EnsureNoInboundActionOnDelete</c> query.</summary>
|
|
private static List<string> FindRowMutatingFkChildren(string path, string parentTable)
|
|
{
|
|
using var conn = Open(path);
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText =
|
|
"SELECT m.name, fk.\"from\", fk.\"on_delete\" " +
|
|
"FROM sqlite_master m JOIN pragma_foreign_key_list(m.name) fk " +
|
|
"WHERE m.type = 'table' AND fk.\"table\" = $t COLLATE NOCASE " +
|
|
"AND UPPER(fk.\"on_delete\") IN ('CASCADE', 'SET NULL', 'SET DEFAULT')";
|
|
cmd.Parameters.AddWithValue("$t", parentTable);
|
|
using var reader = cmd.ExecuteReader();
|
|
var result = new List<string>();
|
|
while (reader.Read())
|
|
result.Add($"{reader.GetString(0)}.{reader.GetString(1)} ON DELETE {reader.GetString(2)}");
|
|
return result;
|
|
}
|
|
|
|
private static HashSet<string> TableNames(string path)
|
|
{
|
|
using var conn = Open(path);
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table'";
|
|
using var reader = cmd.ExecuteReader();
|
|
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
while (reader.Read()) names.Add(reader.GetString(0));
|
|
return names;
|
|
}
|
|
|
|
private static SqliteConnection Open(string path)
|
|
{
|
|
var conn = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = path }.ToString());
|
|
conn.Open();
|
|
return conn;
|
|
}
|
|
|
|
private static string TempDbPath() =>
|
|
Path.Combine(Path.GetTempPath(), $"scadabridge_fkguard_{Guid.NewGuid():N}.db");
|
|
|
|
private static IConfiguration ConfigFor(string path) =>
|
|
new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = path })
|
|
.Build();
|
|
|
|
private static void Cleanup(string path)
|
|
{
|
|
foreach (var suffix in new[] { "", "-wal", "-shm" })
|
|
{
|
|
try { File.Delete(path + suffix); } catch (IOException) { }
|
|
}
|
|
}
|
|
}
|