diff --git a/ZB.MOM.WW.Secrets/CHANGELOG.md b/ZB.MOM.WW.Secrets/CHANGELOG.md
index 38639b1..c042322 100644
--- a/ZB.MOM.WW.Secrets/CHANGELOG.md
+++ b/ZB.MOM.WW.Secrets/CHANGELOG.md
@@ -5,6 +5,29 @@ follows [Keep a Changelog](https://keepachangelog.com/); versions are shared acr
package in the family (see `Directory.Build.props`) even when a given release only touches
one of them.
+## 0.6.4 — unreleased (NOT published to the feed)
+
+### Fixed
+
+- **SQLite store initialisation is safe when several setups run at once on one store**
+ (scadaproj#25). mxaccessgw's parallel test hosts, each migrating the same per-process store,
+ failed intermittently on the Windows nightly with `SqliteException: cannot start a transaction
+ within a transaction` from `SqliteSecretsStoreMigrator.MigrateAsync`. The migrator now:
+ - serialises migrations of one store within a process behind a process-wide gate keyed on the
+ normalised store path (`Path.GetFullPath`; case-insensitive on Windows and macOS), however
+ many factories, migrators or DI containers point at it;
+ - runs each attempt on a dedicated **unpooled** connection inside `BEGIN IMMEDIATE`, so it can
+ neither be handed a pooled handle another owner is still using nor leave one behind 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); both surface as exactly this error on the next
+ `BEGIN`;
+ - retries `SQLITE_BUSY` / `SQLITE_LOCKED` (another process holding the write lock past the 5 s
+ busy timeout) up to 6 attempts on a fresh connection with equal-jitter exponential backoff.
+
+No schema, wire or configuration change; store operations other than the migration are unchanged.
+The Microsoft.Data.Sqlite pin stays at `10.0.7` so this patch does not raise the consumer floor.
+
## 0.6.3 — 2026-09-28
### Added
diff --git a/ZB.MOM.WW.Secrets/CLAUDE.md b/ZB.MOM.WW.Secrets/CLAUDE.md
index 25cc3fd..3c1c83d 100644
--- a/ZB.MOM.WW.Secrets/CLAUDE.md
+++ b/ZB.MOM.WW.Secrets/CLAUDE.md
@@ -4,6 +4,8 @@ Shared encrypted secret store + `${secret:}` resolution for the **ZB.MOM.WW SCAD
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — currently #7 (ignitionoee router pins), #8 (Replicator.Grpc / `0.6.3` fate), #18 (migrator retry jitter). The history below is a record, not a status tracker; file new follow-ups as issues.
+**`0.6.4` — built, NOT published (2026-11-09, scadaproj#25):** `Directory.Build.props` says `0.6.4` but the feed's newest is still `0.6.3`. Concurrent SQLite migration of one store is now gated per normalised path, runs on an unpooled `BEGIN IMMEDIATE` connection and retries `SQLITE_BUSY`/`SQLITE_LOCKED` (see `CHANGELOG.md`). Tests: `tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreConcurrentInitTests.cs` + the gate/retry cases in `SqliteSecretsStoreMigratorTests.cs`; also run on windev.
+
## Index history (migrated from the scadaproj root CLAUDE.md)
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
diff --git a/ZB.MOM.WW.Secrets/Directory.Build.props b/ZB.MOM.WW.Secrets/Directory.Build.props
index b7b288d..13aa4e0 100644
--- a/ZB.MOM.WW.Secrets/Directory.Build.props
+++ b/ZB.MOM.WW.Secrets/Directory.Build.props
@@ -5,7 +5,7 @@
enable
enable
latest
- 0.6.3
+ 0.6.4
true
README.md
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SecretsSqliteConnectionFactory.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SecretsSqliteConnectionFactory.cs
index 25998ee..0d908f7 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SecretsSqliteConnectionFactory.cs
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SecretsSqliteConnectionFactory.cs
@@ -24,12 +24,21 @@ public sealed class SecretsSqliteConnectionFactory
_sqlitePath = sqlitePath;
}
+ ///
+ /// The store path made absolute and canonical (.. segments resolved), so two factories
+ /// built from different spellings of one file agree on its identity. Used to key the
+ /// process-wide migration gate.
+ ///
+ internal string NormalizedPath => Path.GetFullPath(_sqlitePath);
+
///
/// Creates an unopened SQLite connection (Mode=ReadWriteCreate). Prefer
/// , which also applies WAL journaling and the
/// busy timeout.
///
- public SqliteConnection CreateConnection()
+ public SqliteConnection CreateConnection() => CreateConnection(pooling: true);
+
+ private SqliteConnection CreateConnection(bool pooling)
{
string? directory = Path.GetDirectoryName(_sqlitePath);
@@ -42,7 +51,7 @@ public sealed class SecretsSqliteConnectionFactory
{
DataSource = _sqlitePath,
Mode = SqliteOpenMode.ReadWriteCreate,
- Pooling = true,
+ Pooling = pooling,
DefaultTimeout = (int)BusyTimeout.TotalSeconds,
};
@@ -56,9 +65,25 @@ public sealed class SecretsSqliteConnectionFactory
///
/// Cancellation token for the operation.
/// An opened and configured SQLite connection.
- public async Task OpenConnectionAsync(CancellationToken cancellationToken)
+ public Task OpenConnectionAsync(CancellationToken cancellationToken) =>
+ OpenAsync(pooling: true, cancellationToken);
+
+ ///
+ /// Opens a connection that never comes from, and never returns to, the connection pool. The
+ /// migrator uses it so schema creation cannot be handed a pooled handle another owner is still
+ /// using, nor leave one behind (scadaproj#25). Microsoft.Data.Sqlite before 10.0.13 can hand one
+ /// pooled handle to two owners at once (dotnet/efcore#39009), and no 10.0.x release refuses to
+ /// pool a handle whose rollback failed mid-transaction (dotnet/efcore#39082); either surfaces as
+ /// "cannot start a transaction within a transaction" on the next BEGIN.
+ ///
+ /// Cancellation token for the operation.
+ /// An opened, configured, unpooled SQLite connection.
+ internal Task OpenUnpooledConnectionAsync(CancellationToken cancellationToken) =>
+ OpenAsync(pooling: false, cancellationToken);
+
+ private async Task OpenAsync(bool pooling, CancellationToken cancellationToken)
{
- SqliteConnection connection = CreateConnection();
+ SqliteConnection connection = CreateConnection(pooling);
try
{
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsStoreMigrator.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsStoreMigrator.cs
index 5d03594..9de220e 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsStoreMigrator.cs
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsStoreMigrator.cs
@@ -1,3 +1,4 @@
+using System.Collections.Concurrent;
using System.Globalization;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Secrets.Abstractions;
@@ -9,9 +10,33 @@ namespace ZB.MOM.WW.Secrets.Sqlite;
/// safe to run repeatedly. Refuses to run against a database whose on-disk version is
/// newer than this build supports.
///
+///
+/// Safe when several initialisations of one store run at once (scadaproj#25 — mxaccessgw's parallel
+/// test hosts migrating one per-process store failed on Windows with
+/// "cannot start a transaction within a transaction"):
+///
+/// - Migrations of one store inside one process are serialised by a process-wide gate keyed on the
+/// normalised store path, however many factories, migrators or DI containers point at it.
+/// - Each attempt runs on a dedicated, unpooled connection inside BEGIN IMMEDIATE, so it takes the
+/// write lock up front and can neither inherit nor leave behind a pooled handle in a transaction.
+/// - Contention with another process (SQLITE_BUSY / SQLITE_LOCKED after the busy timeout)
+/// is retried on a fresh connection with jittered exponential backoff.
+///
+///
public sealed class SqliteSecretsStoreMigrator(SecretsSqliteConnectionFactory connectionFactory)
: ISecretsStoreMigrator
{
+ /// Total attempts (first try plus retries) on SQLITE_BUSY / SQLITE_LOCKED.
+ internal const int MaxAttempts = 6;
+
+ private const int SqliteBusy = 5;
+ private const int SqliteLocked = 6;
+
+ private static readonly ConcurrentDictionary Gates = new(
+ OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
+ ? StringComparer.OrdinalIgnoreCase
+ : StringComparer.Ordinal);
+
/// Applies the schema migration to the secret store.
/// Cancellation token.
///
@@ -19,11 +44,56 @@ public sealed class SqliteSecretsStoreMigrator(SecretsSqliteConnectionFactory co
///
public async Task MigrateAsync(CancellationToken cancellationToken)
{
- await using SqliteConnection connection =
- await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
+ SemaphoreSlim gate = GateFor(connectionFactory.NormalizedPath);
+ await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ for (int attempt = 1; ; attempt++)
+ {
+ try
+ {
+ await MigrateOnceAsync(cancellationToken).ConfigureAwait(false);
+ return;
+ }
+ catch (SqliteException ex) when (IsTransient(ex) && attempt < MaxAttempts)
+ {
+ await Task.Delay(Backoff(attempt), cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
- await using SqliteTransaction transaction =
- (SqliteTransaction)await connection.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken).ConfigureAwait(false);
+ ///
+ /// The gate serialising migrations of the store at ; exposed so a
+ /// test can prove two spellings of one path share it.
+ ///
+ internal static SemaphoreSlim GateFor(string normalizedPath) =>
+ Gates.GetOrAdd(normalizedPath, static _ => new SemaphoreSlim(1, 1));
+
+ internal static bool IsTransient(SqliteException ex) =>
+ (ex.SqliteErrorCode & 0xFF) is SqliteBusy or SqliteLocked;
+
+ // Equal-jitter exponential backoff: 100, 200, 400, 800, 1600 ms ceilings, at least half of each,
+ // so concurrent processes that collided once do not retry in lockstep.
+ private static TimeSpan Backoff(int attempt)
+ {
+ int ceiling = 100 * (1 << Math.Min(attempt - 1, 4));
+ return TimeSpan.FromMilliseconds((ceiling / 2) + Random.Shared.Next(ceiling / 2 + 1));
+ }
+
+ private async Task MigrateOnceAsync(CancellationToken cancellationToken)
+ {
+ await using SqliteConnection connection =
+ await connectionFactory.OpenUnpooledConnectionAsync(cancellationToken).ConfigureAwait(false);
+
+ // deferred: false => BEGIN IMMEDIATE: take the write lock before reading the version, so two
+ // migrators can never both read "no schema" and then race to create it.
+ await using SqliteTransaction transaction = connection.BeginTransaction(
+ System.Data.IsolationLevel.Serializable, deferred: false);
int existingVersion =
await ReadExistingSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreConcurrentInitTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreConcurrentInitTests.cs
new file mode 100644
index 0000000..61f8659
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreConcurrentInitTests.cs
@@ -0,0 +1,217 @@
+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;
+
+///
+/// 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.
+///
+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
+ {
+ ["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()
+ .MigrateAsync(default).GetAwaiter().GetResult();
+ foreach (IHostedService hosted in provider.GetServices())
+ {
+ if (hosted.GetType().Name == "SecretsMigrationHostedService")
+ {
+ await hosted.StartAsync(CancellationToken.None);
+ }
+ }
+
+ _ = await provider.GetRequiredService().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.
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreMigratorTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreMigratorTests.cs
index c11672f..a8ff306 100644
--- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreMigratorTests.cs
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreMigratorTests.cs
@@ -78,6 +78,80 @@ public sealed class SqliteSecretsStoreMigratorTests : IDisposable
() => migrator.MigrateAsync(CancellationToken.None));
}
+ [Fact]
+ public void NormalizedPath_DifferentSpellingsOfOneFile_ShareOneMigrationGate()
+ {
+ string dir = Path.GetDirectoryName(_dbPath)!;
+ string detour = Path.Combine(dir, "zb-detour", "..", Path.GetFileName(_dbPath));
+
+ var canonical = new SecretsSqliteConnectionFactory(_dbPath);
+ var other = new SecretsSqliteConnectionFactory(detour);
+
+ Assert.Equal(canonical.NormalizedPath, other.NormalizedPath);
+ Assert.Same(
+ SqliteSecretsStoreMigrator.GateFor(canonical.NormalizedPath),
+ SqliteSecretsStoreMigrator.GateFor(other.NormalizedPath));
+ Assert.NotSame(
+ SqliteSecretsStoreMigrator.GateFor(canonical.NormalizedPath),
+ SqliteSecretsStoreMigrator.GateFor(canonical.NormalizedPath + ".other"));
+ }
+
+ [Fact]
+ public async Task Migrate_WaitsForTheProcessWideGateOfItsStore()
+ {
+ var factory = new SecretsSqliteConnectionFactory(_dbPath);
+ SemaphoreSlim gate = SqliteSecretsStoreMigrator.GateFor(factory.NormalizedPath);
+
+ await gate.WaitAsync();
+ Task migration;
+ try
+ {
+ // A second factory/migrator pair, as a separate DI container would build.
+ migration = new SqliteSecretsStoreMigrator(new SecretsSqliteConnectionFactory(_dbPath))
+ .MigrateAsync(CancellationToken.None);
+
+ await Task.Delay(300);
+ Assert.False(migration.IsCompleted, "migration ran while another migration of the same store held the gate");
+ }
+ finally
+ {
+ gate.Release();
+ }
+
+ await migration.WaitAsync(TimeSpan.FromSeconds(30));
+ Assert.Equal(SqliteSecretsSchema.CurrentVersion, await ReadSchemaVersionAsync(factory));
+ }
+
+ [Fact]
+ public async Task Migrate_RetriesWhenAnotherWriterHoldsTheStorePastTheBusyTimeout()
+ {
+ var factory = new SecretsSqliteConnectionFactory(_dbPath);
+
+ // Another "process": an unpooled connection holding the write lock (BEGIN IMMEDIATE) for longer
+ // than the 5 s busy timeout, so the first migration attempt surfaces SQLITE_BUSY.
+ await using SqliteConnection holder = await factory.OpenUnpooledConnectionAsync(CancellationToken.None);
+ SqliteTransaction lockTx = holder.BeginTransaction(System.Data.IsolationLevel.Serializable, deferred: false);
+
+ Task migration = new SqliteSecretsStoreMigrator(factory).MigrateAsync(CancellationToken.None);
+ await Task.Delay(TimeSpan.FromSeconds(6.5));
+ Assert.False(migration.IsCompleted, "migration finished while the write lock was still held");
+
+ lockTx.Rollback();
+ lockTx.Dispose();
+
+ await migration.WaitAsync(TimeSpan.FromSeconds(30));
+ Assert.Equal(SqliteSecretsSchema.CurrentVersion, await ReadSchemaVersionAsync(factory));
+ }
+
+ [Theory]
+ [InlineData(5, true)] // SQLITE_BUSY
+ [InlineData(6, true)] // SQLITE_LOCKED
+ [InlineData(261, true)] // SQLITE_BUSY_RECOVERY (extended code of BUSY)
+ [InlineData(1, false)] // SQLITE_ERROR, e.g. "cannot start a transaction within a transaction"
+ [InlineData(11, false)] // SQLITE_CORRUPT
+ public void IsTransient_OnlyBusyAndLockedAreRetried(int errorCode, bool expected) =>
+ Assert.Equal(expected, SqliteSecretsStoreMigrator.IsTransient(new SqliteException("x", errorCode)));
+
private static async Task ReadSchemaVersionAsync(SecretsSqliteConnectionFactory factory)
{
await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None);