Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreConcurrentInitTests.cs
T
Joseph Doherty f21f2e551d fix(secrets): make concurrent SQLite store initialisation safe; bump to 0.6.4 (unpublished) (scadaproj#25)
Several setups migrating one SQLite secret store at once (mxaccessgw's parallel test hosts on the
per-process store) failed intermittently on Windows with "cannot start a transaction within a
transaction" from SqliteSecretsStoreMigrator.MigrateAsync. That message means the BEGIN ran on a
handle that was already in a transaction: the pinned Microsoft.Data.Sqlite 10.0.7 can hand one
pooled handle to two owners (dotnet/efcore#39009, fixed in 10.0.13) and no 10.0.x refuses to
re-pool a handle whose rollback failed (dotnet/efcore#39082).

The migrator now serialises migrations of one store per process behind a gate keyed on the
normalised path, runs each attempt on a dedicated unpooled connection inside BEGIN IMMEDIATE, and
retries SQLITE_BUSY/SQLITE_LOCKED (another process past the busy timeout) with equal-jitter
exponential backoff. Store operations and the Microsoft.Data.Sqlite pin are unchanged.

Tests: concurrent-init (16 hosts x 20 rounds on fresh and existing stores, two path spellings,
16 AddZbSecrets containers running the pre-host migrator + hosted service), gate sharing/waiting,
BUSY retry past the 5 s busy timeout, transient-code classification.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:57:53 -05:00

218 lines
8.4 KiB
C#

using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.Secrets.Tests.Sqlite;
/// <summary>
/// scadaproj#25: several hosts in one process initialising the SAME SQLite secret store at once
/// (mxaccessgw's parallel test classes, each building a host whose migration hosted service and
/// store point at the per-process default path). On Windows this surfaced as
/// "cannot start a transaction within a transaction". Each simulated host owns its own
/// connection factory, migrator and store, exactly as separate DI containers would.
/// </summary>
public sealed class SqliteSecretsStoreConcurrentInitTests : IDisposable
{
private const int Hosts = 16;
private const int Rounds = 20;
private readonly string _root =
Path.Combine(Path.GetTempPath(), $"zb-secrets-concurrent-{Guid.NewGuid():N}");
[Fact]
public async Task ConcurrentInitialisation_OfOneFreshStore_AllHostsSucceed()
{
for (int round = 0; round < Rounds; round++)
{
string path = Path.Combine(_root, $"round-{round}", "secrets.db");
await RunHostsAsync(path, round);
await AssertStoreAsync(path, expectedSecrets: Hosts);
}
}
[Fact]
public async Task ConcurrentInitialisation_OfOneExistingStore_AllHostsSucceed()
{
string path = Path.Combine(_root, "existing", "secrets.db");
await new SqliteSecretsStoreMigrator(new SecretsSqliteConnectionFactory(path))
.MigrateAsync(CancellationToken.None);
for (int round = 0; round < Rounds; round++)
{
await RunHostsAsync(path, round);
}
await AssertStoreAsync(path, expectedSecrets: Hosts * Rounds);
}
[Fact]
public async Task ConcurrentInitialisation_ThroughDifferentSpellingsOfOnePath_AllHostsSucceed()
{
// The per-path gate keys on the NORMALISED path: a relative spelling and a "..\" detour
// must land on the same gate as the canonical absolute path.
string dir = Path.Combine(_root, "spellings");
string canonical = Path.Combine(dir, "secrets.db");
string detour = Path.Combine(dir, "sub", "..", "secrets.db");
string[] spellings = [canonical, detour];
Task[] hosts = new Task[Hosts];
TaskCompletionSource start = new(TaskCreationOptions.RunContinuationsAsynchronously);
for (int i = 0; i < Hosts; i++)
{
string spelling = spellings[i % spellings.Length];
int host = i;
hosts[i] = Task.Run(async () =>
{
await start.Task;
await RunOneHostAsync(spelling, $"spell/{host}");
});
}
start.SetResult();
await Task.WhenAll(hosts);
await AssertStoreAsync(canonical, expectedSecrets: Hosts);
}
[Fact]
public async Task ConcurrentInitialisation_ThroughSeparateAddZbSecretsContainers_AllSucceed()
{
// The exact mxaccessgw shape: every host builds its own container with AddZbSecrets, runs the
// pre-host migrator, then starts the SecretsMigrationHostedService — all on one store path.
string path = Path.Combine(_root, "di", "secrets.db");
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Secrets:SqlitePath"] = path,
["Secrets:RunMigrationsOnStartup"] = "true",
})
.Build();
TaskCompletionSource start = new(TaskCreationOptions.RunContinuationsAsynchronously);
Task[] hosts = new Task[Hosts];
for (int i = 0; i < Hosts; i++)
{
hosts[i] = Task.Run(async () =>
{
await using ServiceProvider provider = new ServiceCollection()
.AddZbSecrets(configuration, "Secrets")
.BuildServiceProvider();
await start.Task;
provider.GetRequiredService<SqliteSecretsStoreMigrator>()
.MigrateAsync(default).GetAwaiter().GetResult();
foreach (IHostedService hosted in provider.GetServices<IHostedService>())
{
if (hosted.GetType().Name == "SecretsMigrationHostedService")
{
await hosted.StartAsync(CancellationToken.None);
}
}
_ = await provider.GetRequiredService<ISecretStore>().ListAsync(includeDeleted: true, CancellationToken.None);
});
}
start.SetResult();
await Task.WhenAll(hosts);
await AssertStoreAsync(path, expectedSecrets: 0);
}
private static async Task RunHostsAsync(string path, int round)
{
TaskCompletionSource start = new(TaskCreationOptions.RunContinuationsAsynchronously);
Task[] hosts = new Task[Hosts];
for (int i = 0; i < Hosts; i++)
{
int host = i;
hosts[i] = Task.Run(async () =>
{
await start.Task;
await RunOneHostAsync(path, $"r{round}/h{host}");
});
}
// Release every host at once so their migrations genuinely overlap.
start.SetResult();
await Task.WhenAll(hosts);
}
private static async Task RunOneHostAsync(string path, string secretName)
{
// One "host": its own factory, migrator and store, as a separate DI container would build.
SecretsSqliteConnectionFactory factory = new(path);
await new SqliteSecretsStoreMigrator(factory).MigrateAsync(CancellationToken.None);
SqliteSecretStore store = new(factory);
StoredSecret row = MakeSecret(secretName);
await store.UpsertAsync(row, CancellationToken.None);
await store.ApplyReplicatedAsync(row with { Revision = 5, UpdatedUtc = row.UpdatedUtc.AddSeconds(1) }, CancellationToken.None);
Assert.NotNull(await store.GetAsync(row.Name, CancellationToken.None));
_ = await store.ListAsync(includeDeleted: false, CancellationToken.None);
}
private static async Task AssertStoreAsync(string path, int expectedSecrets)
{
SecretsSqliteConnectionFactory factory = new(path);
await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None);
await using SqliteCommand version = connection.CreateCommand();
version.CommandText = "SELECT version FROM schema_version WHERE id = 1;";
Assert.Equal((long)SqliteSecretsSchema.CurrentVersion, Convert.ToInt64(await version.ExecuteScalarAsync()));
await using SqliteCommand count = connection.CreateCommand();
count.CommandText = "SELECT COUNT(*) FROM secret;";
Assert.Equal((long)expectedSecrets, Convert.ToInt64(await count.ExecuteScalarAsync()));
// No pooled connection may be handed back with a transaction still open: a fresh
// BEGIN on a pooled connection is exactly what failed on Windows.
await using SqliteTransaction tx = (SqliteTransaction)
await connection.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
await tx.CommitAsync();
}
private static StoredSecret MakeSecret(string name) => new()
{
Name = new SecretName(name),
Description = "concurrent-init",
ContentType = SecretContentType.ConnectionString,
Ciphertext = [1, 2, 3],
Nonce = [4, 5, 6],
Tag = [7, 8, 9],
WrappedDek = [10, 11, 12],
WrapNonce = [13, 14, 15],
WrapTag = [16, 17, 18],
KekId = "kek-1",
Revision = 0,
IsDeleted = false,
DeletedUtc = null,
CreatedUtc = DateTimeOffset.UtcNow,
UpdatedUtc = DateTimeOffset.UtcNow,
CreatedBy = "concurrent",
UpdatedBy = "concurrent",
};
public void Dispose()
{
SqliteConnection.ClearAllPools();
try
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
catch (IOException)
{
// Best-effort temp cleanup.
}
catch (UnauthorizedAccessException)
{
// Best-effort temp cleanup.
}
}
}