diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryTranslationVariantTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryTranslationVariantTests.cs index 9bbbf54..a160095 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryTranslationVariantTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryTranslationVariantTests.cs @@ -172,6 +172,25 @@ public void The_catalogue_reaches_every_kind_site_accessor_and_converter() all.Count(@case => Expect(@case) is NullsLeftOut).ShouldBeGreaterThan(5); } + /// + /// A guard on the harness: one seeded database read by many cases at once, as this class and + /// read it from two collections, answers each the same rows, never + /// "database is locked" (#148). + /// + [Fact] + public void A_seeded_database_read_by_many_cases_at_once_answers_each_alike() + { + var @case = QueryShapes.All().First(@case => @case is { Op: Op.OrderBy, Site: Site.Nullable } && Run(@case, valueObject: true).Rows is not null); + var first = Run(@case, valueObject: true); + + var answers = new ConcurrentBag(); + Parallel.For(0, 400, new ParallelOptions { MaxDegreeOfParallelism = Math.Max(8, Environment.ProcessorCount * 2) }, + _ => answers.Add(Run(@case, valueObject: true))); + + answers.ShouldAllBe(answer => answer.Refusal == null && answer.Rows!.SequenceEqual(first.Rows!), + answers.FirstOrDefault(answer => answer.Refusal != null) is { } refused ? Shown(refused) : null); + } + internal abstract record Expectation; /// Answers as the same query over the wrapped type. @@ -249,7 +268,11 @@ private static string Refusal(Answer answer, string expected) internal sealed record Answer(string[]? Rows, string? Sql, Exception? Refusal); - /// One in-memory SQLite database per model, seeded once and only read. + /// + /// One in-memory SQLite database per model, seeded once and only read, one query at a time: a connection is not + /// thread-safe, and reads the same databases from its own collection, in + /// parallel with this one, where two commands at once are "database is locked" (#148). + /// private static readonly ConcurrentDictionary> Databases = new(); internal static Answer Run(QueryCase @case, bool valueObject) @@ -262,16 +285,19 @@ internal static Answer Run(QueryCase @case, bool valueObject) var (connection, refusal) = Databases.GetOrAdd(key, _ => new Lazy<(SqliteConnection, Exception?)>(() => Seeded(model, builder))).Value; if (refusal is not null) return new Answer(null, null, refusal); - using var db = QueryDb.Create(model, connection); - try - { - var set = (IQueryable)typeof(DbContext).GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!.MakeGenericMethod(builder.Source).Invoke(db, null)!; - var (query, ordered) = builder.Build(set.Expression); - return Execute(set.Provider, query, ordered, sql: true); - } - catch (Exception exception) when (exception is not ShouldAssertException) + lock (connection) { - return new Answer(null, null, Unwrapped(exception)); + using var db = QueryDb.Create(model, connection); + try + { + var set = (IQueryable)typeof(DbContext).GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!.MakeGenericMethod(builder.Source).Invoke(db, null)!; + var (query, ordered) = builder.Build(set.Expression); + return Execute(set.Provider, query, ordered, sql: true); + } + catch (Exception exception) when (exception is not ShouldAssertException) + { + return new Answer(null, null, Unwrapped(exception)); + } } }