diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbLegacyMigrator.cs b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbLegacyMigrator.cs index 29f0eddd..ce040715 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbLegacyMigrator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/SiteLocalDbLegacyMigrator.cs @@ -415,7 +415,7 @@ public static class SiteLocalDbLegacyMigrator /// private static List? ReadAll(string legacyPath, string sql, int expectedColumns) { - using var connection = new SqliteConnection($"Data Source={legacyPath};Mode=ReadOnly"); + using var connection = new SqliteConnection(LegacyReadOnlyConnectionString(legacyPath)); connection.Open(); using var cmd = connection.CreateCommand(); @@ -450,11 +450,34 @@ public static class SiteLocalDbLegacyMigrator private static SqliteConnection OpenLegacyReadOnly(string legacyPath) { - var connection = new SqliteConnection($"Data Source={legacyPath};Mode=ReadOnly"); + var connection = new SqliteConnection(LegacyReadOnlyConnectionString(legacyPath)); connection.Open(); return connection; } + /// + /// Read-only connection string for a legacy file, with connection pooling DISABLED. + /// + /// + /// + /// Pooling=False is load-bearing, not a tuning choice. Microsoft.Data.Sqlite pools + /// connections by default, so disposing one returns it to the pool and leaves the + /// underlying sqlite3 handle — and therefore the OS file handle — open. Every migrate step + /// finishes by renaming its legacy file (), and on Windows + /// against a file someone still holds open throws + /// ("being used by another process"). The whole migration then + /// faults out of AddZbLocalDb's factory and the node cannot boot at all. + /// + /// + /// This never shows up on Linux or macOS: POSIX rename does not care about open handles, + /// so the docker rig and developer machines migrate cleanly. It reproduces on the first + /// Windows site node that boots with a pre-Phase-2 database beside it — which is every + /// Windows install being upgraded, exactly once. + /// + /// + internal static string LegacyReadOnlyConnectionString(string legacyPath) + => $"Data Source={legacyPath};Mode=ReadOnly;Pooling=False"; + /// /// Returns the subset of that the legacy table actually has, /// in the caller's order. An absent table yields an empty list rather than throwing. diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbLegacyMigratorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbLegacyMigratorTests.cs index 329b1011..c8fc2e7b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbLegacyMigratorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SiteLocalDbLegacyMigratorTests.cs @@ -228,6 +228,29 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable Assert.True(File.Exists(trackingPath + ".migrated")); } + [Fact] + public void LegacyReadsDisablePooling_OrTheRenameFailsOnWindows() + { + // Deliberately white-box, and deliberately NOT a behavioural test. + // + // Microsoft.Data.Sqlite pools connections, so disposing one returns it to the pool + // and leaves the underlying sqlite3 handle — and the OS file handle — open. Every + // migrate step then renames its legacy file. On Windows, File.Move against a file + // someone still holds open throws IOException, the migration faults out of + // AddZbLocalDb's factory, and the site node cannot boot at all. + // + // It cannot be reproduced from a behavioural assertion off Windows: POSIX rename does + // not care about open handles, so every rename test above — including + // LegacyTrackingRows_AreCopiedAndTheFileIsRenamed — passes on Linux and macOS with the + // pooling bug fully present. That is exactly how it shipped and reached a production + // Windows box. Asserting on the connection string is the only guard that holds on the + // platform this suite actually runs on. + var cs = SiteLocalDbLegacyMigrator.LegacyReadOnlyConnectionString(Path_("whatever.db")); + + Assert.Contains("Pooling=False", cs, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Mode=ReadOnly", cs, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void LegacyEvents_GetDeterministicIds_NotFreshGuids() {