From 45e9e2efb7b932e3d7204d380e1e067ac210eacf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Thu, 29 Oct 2026 15:40:54 +0100 Subject: [PATCH] test: a seeded SQLite database answers one query at a time QueryTranslationVariantTests caches one open in-memory SqliteConnection per model. Since #142, SqlServerQueryVariantTests reads the same cache from its own collection, in parallel with this class. A connection is not thread-safe, so two commands at once failed with "database is locked" on the Linux leg, the only leg that runs the Docker classes: twice on #141's run 40889709782. Each read now holds the connection's lock. A_seeded_database_read_by_many_cases_at_once_answers_each_alike runs 400 reads of one database in parallel. With the lock taken out it fails with "database is locked"; with it, it passes, and the class's 9,610 cases pass in 52 s. Closes #148 Co-Authored-By: Claude Opus 5.5 --- .../QueryTranslationVariantTests.cs | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) 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)); + } } }