fix(localdb): close both Phase 1 live-gate follow-ups
1. Wiped-node back-fill (live gate check 4). Fixed upstream in ZB.MOM.WW.LocalDb 0.1.2 and pinned here. The gate's diagnosis was slightly off: the oplog cap was not the gate. Snapshot detection measured the peer's gap against the oldest surviving oplog row and read an empty oplog as "no gap possible" — and an empty oplog is the steady state of a converged pair, since ack-pruning deletes everything the peer confirmed. The healthy state was the one state that could not heal a wiped peer. Now measured against last_acked_seq when the oplog is empty. Pinned at this level too, not just the library's: the pair harness grows a WipePassiveAsync (a NEW database — a rebuilt node comes back with a new node id and a zero watermark), and the new scenario asserts the emptied oplog as its precondition before wiping, then requires the deployment artifact to come back byte-identical with no new deploy. Verified RED against the pinned 0.1.1 (times out waiting for back-fill) and green on 0.1.2. 2. Oplog growth on default-OFF nodes (live gate check 8), fixed at the source: StoreAsync now skips entirely when the pointer already names this deployment/revision, the SHA matches the bytes, and the expected chunk count is present. Re-caching an artifact the node already holds — every restart's boot-from-cache, every RestoreApplied — writes nothing and mints no oplog rows, where before it cost a delete plus an insert per chunk plus a pointer update, all identical to what was already there. Identity is over the bytes and the chunks are counted, both deliberately: a re-composed artifact can carry the same ids with different bytes, and a pointer can name a deployment whose chunks are missing, where skipping would make an unreadable cache permanent. Both guards verified RED against a naive pointer-only skip. The gate's stated bound was also wrong and is corrected in the doc: growth was never headed for the 1M row cap. AddZbLocalDbReplication is registered unconditionally, so MaintenanceBackgroundService runs on default-OFF nodes and the 7-day MaxOplogAge cap prunes. Runtime.Tests 412/0/31, Host.IntegrationTests LocalDb 45/45. Pre-existing and unrelated: AbCip_Green_AgainstSim (needs the AB CIP docker fixture, red on a clean master too) and a flaky Roslyn race test (green 2/2 in isolation). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -69,10 +69,13 @@ public sealed class LocalDbPairHarness : IAsyncDisposable
|
||||
private readonly string _apiKeyB;
|
||||
|
||||
private readonly string _pathA = Path.Combine(Path.GetTempPath(), $"otopcua-pairA-{Guid.NewGuid():N}.db");
|
||||
private readonly string _pathB = Path.Combine(Path.GetTempPath(), $"otopcua-pairB-{Guid.NewGuid():N}.db");
|
||||
|
||||
// Not readonly: WipePassiveAsync replaces node B's database wholesale, which is what a rebuilt
|
||||
// node with a lost volume actually is — a new file, and with it a new node id.
|
||||
private string _pathB = Path.Combine(Path.GetTempPath(), $"otopcua-pairB-{Guid.NewGuid():N}.db");
|
||||
|
||||
private readonly ServiceProvider _dbProviderA;
|
||||
private readonly ServiceProvider _dbProviderB;
|
||||
private ServiceProvider _dbProviderB;
|
||||
|
||||
private IHost? _serverHost; // node B — passive
|
||||
private IHost? _initiatorHost; // node A — dials the peer
|
||||
@@ -125,6 +128,30 @@ public sealed class LocalDbPairHarness : IAsyncDisposable
|
||||
_serverHost = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destroys node B's database and replaces it with an empty one, leaving the pairing config
|
||||
/// untouched — the rebuilt-node case: a lost volume, a re-imaged host, a fresh container.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A new file, not a truncated one, because that is what the real thing is: the node id lives
|
||||
/// in the database, so a rebuilt node comes back with a new identity and a zero watermark. The
|
||||
/// caller follows with <see cref="RestartPairAsync"/> to bring the wiped node back online.
|
||||
/// </remarks>
|
||||
public async Task WipePassiveAsync()
|
||||
{
|
||||
await StopPassiveAsync();
|
||||
await _dbProviderB.DisposeAsync();
|
||||
|
||||
// Pooled connections outlive the provider; without this the delete silently no-ops on
|
||||
// Windows and the "wiped" node would still hold its rows.
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
DeleteDatabaseFiles(_pathB);
|
||||
|
||||
_pathB = Path.Combine(Path.GetTempPath(), $"otopcua-pairB-{Guid.NewGuid():N}.db");
|
||||
_dbProviderB = BuildDatabaseProvider(_pathB);
|
||||
_ = B;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brings node B back on a NEW loopback port and re-dials from A. The initiator re-reads the
|
||||
/// peer address on each reconnect, so this is a genuine rejoin over the same databases.
|
||||
@@ -201,6 +228,17 @@ public sealed class LocalDbPairHarness : IAsyncDisposable
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts oplog rows on a node. Zero means every local change has been acked by the peer and
|
||||
/// pruned — the steady state of a converged pair, and the state from which a wiped peer can
|
||||
/// only be healed by a snapshot.
|
||||
/// </summary>
|
||||
public static async Task<long> OplogDepthAsync(ILocalDb db)
|
||||
{
|
||||
var rows = await db.QueryAsync("SELECT COUNT(*) FROM __localdb_oplog", static r => r.GetInt64(0));
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/// <summary>Counts tombstone rows in <c>__localdb_row_version</c> for one table on a node.</summary>
|
||||
public static async Task<long> TombstoneCountAsync(ILocalDb db, string table)
|
||||
{
|
||||
@@ -313,12 +351,16 @@ public sealed class LocalDbPairHarness : IAsyncDisposable
|
||||
await _dbProviderB.DisposeAsync();
|
||||
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
foreach (var path in new[] { _pathA, _pathB })
|
||||
DeleteDatabaseFiles(_pathA);
|
||||
DeleteDatabaseFiles(_pathB);
|
||||
}
|
||||
|
||||
/// <summary>Deletes a SQLite database and its WAL/shm sidecars, best effort.</summary>
|
||||
private static void DeleteDatabaseFiles(string path)
|
||||
{
|
||||
foreach (var suffix in new[] { "", "-wal", "-shm" })
|
||||
{
|
||||
foreach (var suffix in new[] { "", "-wal", "-shm" })
|
||||
{
|
||||
try { File.Delete(path + suffix); } catch { /* best effort */ }
|
||||
}
|
||||
try { File.Delete(path + suffix); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user