fix(host): clear pooled SQLite handles before renaming the imported inbound-key file — Windows refused the rename, so the import re-ran every boot (QA vq03/vq04 2026-10-22)
This commit is contained in:
@@ -71,6 +71,12 @@ about this at boot). To move an existing pair over:
|
|||||||
5. Check the import log line on each node (`grep -i "api-key store import\|zbauth"` in the
|
5. Check the import log line on each node (`grep -i "api-key store import\|zbauth"` in the
|
||||||
node's log) — one node reports the keys it inserted, the other reports the ones it found
|
node's log) — one node reports the keys it inserted, the other reports the ones it found
|
||||||
already present (skipped); together they cover the union of both nodes' prior SQLite files.
|
already present (skipped); together they cover the union of both nodes' prior SQLite files.
|
||||||
|
On a **Windows** central running a build before 2026-10-22 the import succeeds but the
|
||||||
|
`*.imported` rename then fails with `IOException: … being used by another process` (the
|
||||||
|
importer's pooled SQLite handle; fixed by clearing the pools before the rename) — the import
|
||||||
|
re-runs harmlessly on every boot. Fix it by hand: stop that node's Central, wait for its
|
||||||
|
`ZB.MOM.WW.ScadaBridge.Host` process to exit, rename `inbound-api-keys.sqlite` and its
|
||||||
|
`-wal`/`-shm` siblings to `*.imported`, start it again (QA, 2026-10-22).
|
||||||
6. Run `security api-key list` against each node's management endpoint and confirm both show
|
6. Run `security api-key list` against each node's management endpoint and confirm both show
|
||||||
the same keys.
|
the same keys.
|
||||||
7. After a quarantine period (once you're confident the import is correct and no rollback is
|
7. After a quarantine period (once you're confident the import is correct and no rollback is
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
using ZB.MOM.WW.Auth.ApiKeys.SqlServer;
|
using ZB.MOM.WW.Auth.ApiKeys.SqlServer;
|
||||||
@@ -112,6 +113,12 @@ internal sealed class InboundApiKeyStoreImportService(
|
|||||||
path, result.KeysInserted, result.KeysSkipped,
|
path, result.KeysInserted, result.KeysSkipped,
|
||||||
result.AuditInserted, result.AuditSkipped, result.SourceFound);
|
result.AuditInserted, result.AuditSkipped, result.SourceFound);
|
||||||
|
|
||||||
|
// The library importer reads the file through a POOLED SqliteConnection, and the pool
|
||||||
|
// keeps the OS handle open after the connection is disposed. Linux renames an open file
|
||||||
|
// happily, but Windows refuses ("being used by another process"), so on a Windows
|
||||||
|
// central the rename failed on every boot and the import re-ran each time (QA
|
||||||
|
// vq03/vq04, 2026-10-22). Drop the idle pooled handles first.
|
||||||
|
SqliteConnection.ClearAllPools();
|
||||||
RenameImported(path);
|
RenameImported(path);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -60,6 +60,35 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable
|
|||||||
Assert.True(File.Exists(path + "-shm.imported"));
|
Assert.True(File.Exists(path + "-shm.imported"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that the rename succeeds after the importer read the file through a pooled
|
||||||
|
/// SQLite connection — the library's real behaviour. The idle pooled handle stays open after
|
||||||
|
/// the connection is disposed, and Windows refuses to rename a file with an open handle (the
|
||||||
|
/// failure seen on the QA centrals 2026-10-22); the service must release pooled handles first.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_ImporterLeftPooledSqliteHandle_StillRenamesFile()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(_dir, "inbound-api-keys.sqlite");
|
||||||
|
var importer = new PooledSqliteReadingImporter();
|
||||||
|
var service = CreateService(importer, sqlitePath: path);
|
||||||
|
|
||||||
|
await using (var create = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={path};Pooling=True"))
|
||||||
|
{
|
||||||
|
await create.OpenAsync();
|
||||||
|
await using var cmd = create.CreateCommand();
|
||||||
|
cmd.CommandText = "CREATE TABLE api_keys (key_id TEXT PRIMARY KEY)";
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await service.StartAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(importer.Read);
|
||||||
|
Assert.False(File.Exists(path));
|
||||||
|
Assert.True(File.Exists(path + ".imported"));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that the importer is not called when the file is absent.</summary>
|
/// <summary>Verifies that the importer is not called when the file is absent.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -229,6 +258,25 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable
|
|||||||
Options.Create(new ApiKeyOptions { SqlitePath = sqlitePath }),
|
Options.Create(new ApiKeyOptions { SqlitePath = sqlitePath }),
|
||||||
NullLogger<InboundApiKeyStoreImportService>.Instance);
|
NullLogger<InboundApiKeyStoreImportService>.Instance);
|
||||||
|
|
||||||
|
/// <summary>Reads the file through a pooled connection and disposes it, as the library importer does.</summary>
|
||||||
|
private sealed class PooledSqliteReadingImporter : IInboundApiKeyStoreImporter
|
||||||
|
{
|
||||||
|
/// <summary>Gets a value indicating whether the file was read.</summary>
|
||||||
|
public bool Read { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<ApiKeyImportResult> ImportAsync(string sqlitePath, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={sqlitePath};Pooling=True");
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
await using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT COUNT(*) FROM api_keys";
|
||||||
|
await cmd.ExecuteScalarAsync(ct);
|
||||||
|
Read = true;
|
||||||
|
return new ApiKeyImportResult(0, 0, 0, 0, SourceFound: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeImporter : IInboundApiKeyStoreImporter
|
private sealed class FakeImporter : IInboundApiKeyStoreImporter
|
||||||
{
|
{
|
||||||
private int _calls;
|
private int _calls;
|
||||||
|
|||||||
Reference in New Issue
Block a user