From fbcb9f8b504588b3a231c4f852875f3a6ccbd220 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 6 May 2026 14:57:30 +0200 Subject: [PATCH 01/11] Add sync benchmarks --- .github/workflows/fw-lite.yaml | 24 +- backend/Directory.Packages.props | 1 + .../BenchmarkSupport.cs | 56 ++++ .../Fixtures/Sena3Collection.cs | 13 + .../FwLiteProjectSync.Tests.csproj | 1 + .../FwLiteProjectSync.Tests/Sena3SyncTests.cs | 3 +- .../FwLiteProjectSync.Tests/SyncBenchmark.cs | 91 +++++++ .../SyncMutationBenchmark.cs | 250 ++++++++++++++++++ .../XUnitBenchmarkLogger.cs | 40 +++ 9 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 backend/FwLite/FwLiteProjectSync.Tests/BenchmarkSupport.cs create mode 100644 backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3Collection.cs create mode 100644 backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs create mode 100644 backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs create mode 100644 backend/FwLite/FwLiteProjectSync.Tests/XUnitBenchmarkLogger.cs diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index 1d6c2bbdef..792ff2336f 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -64,7 +64,29 @@ jobs: run: task fw-lite:has-pending-model-changes -- --no-build - name: Dotnet test - run: dotnet test FwLiteOnly.slnf --logger GitHubActions --no-build -p:BuildAndroid=false + # Benchmarks run in a separate Release-mode job — see `benchmark` below. + run: dotnet test FwLiteOnly.slnf --logger GitHubActions --no-build -p:BuildAndroid=false --filter "Category!=Benchmark" + + benchmark: + name: Run FW Lite sync benchmarks + # BenchmarkDotNet timings are only meaningful in Release; this job runs the FwLiteProjectSync.Tests + # project in Release with --filter "Category=Benchmark" so threshold assertions actually fire. + timeout-minutes: 40 + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.x' + + - name: Dotnet build (Release) + run: dotnet build backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj -c Release + + - name: Dotnet test (benchmarks) + run: dotnet test backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj -c Release --logger GitHubActions --no-build --filter "Category=Benchmark" frontend: runs-on: ubuntu-latest diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index bf493b9a70..805cfc9e14 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -9,6 +9,7 @@ + diff --git a/backend/FwLite/FwLiteProjectSync.Tests/BenchmarkSupport.cs b/backend/FwLite/FwLiteProjectSync.Tests/BenchmarkSupport.cs new file mode 100644 index 0000000000..5ea983706f --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync.Tests/BenchmarkSupport.cs @@ -0,0 +1,56 @@ +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Engines; +using BenchmarkDotNet.Exporters.Json; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Reports; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; +using FluentAssertions.Execution; +using Xunit.Abstractions; + +namespace FwLiteProjectSync.Tests; + +internal static class BenchmarkSupport +{ + /// + /// Standard config for sync benchmarks: in-process toolchain, cold iterations, and a logger + /// that pipes the BDN summary table into xUnit's test output. + /// + /// + /// The in-process toolchain is what lets a benchmark class read a static field set by its + /// xUnit driver (see FirstSyncBench.Fixture) — a separate-process toolchain would + /// fork a clean AppDomain and lose that reference. + /// + /// The default timeout is 5 min, which is too short + /// for multi-iteration sync benchmarks (e.g. delete-heavy alone is ~80s/iter and full-import + /// setup adds ~50s/iter, so 5 iterations need ~11 min). 30 min covers the worst case with + /// headroom. + /// + public static IConfig ConfigFor(ITestOutputHelper output) + { + var toolchain = new InProcessNoEmitToolchain(TimeSpan.FromMinutes(30), logOutput: false); + return ManualConfig.CreateEmpty() + .AddJob(Job.Default + .WithStrategy(RunStrategy.ColdStart) + .WithIterationCount(5) + .WithToolchain(toolchain)) + .AddExporter(JsonExporter.FullCompressed) + .AddColumnProvider(DefaultColumnProviders.Instance) + .AddLogger(new XUnitBenchmarkLogger(output)); + } + + /// + /// Asserts the benchmark run produced usable measurements. Call inside an + /// so per-report threshold failures still surface. + /// + public static void AssertRunWasSuccessful(Summary summary) + { + summary.HasCriticalValidationErrors.Should().BeFalse("BenchmarkDotNet reported critical validation errors"); + summary.Reports.Should().NotBeEmpty("BenchmarkDotNet produced no reports"); + foreach (var report in summary.Reports) + { + report.Success.Should().BeTrue($"benchmark {report.BenchmarkCase.DisplayInfo} should have completed without error"); + report.ResultStatistics.Should().NotBeNull($"benchmark {report.BenchmarkCase.DisplayInfo} should have produced statistics"); + } + } +} diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3Collection.cs b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3Collection.cs new file mode 100644 index 0000000000..7226c6629b --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3Collection.cs @@ -0,0 +1,13 @@ +namespace FwLiteProjectSync.Tests.Fixtures; + +/// +/// Groups every test class that uses into a single xUnit +/// collection so they share one fixture instance and run serially. Without this, parallel +/// classes each spin up their own fixture and race on the shared ./Sena3Fixture/ +/// folder during . +/// +[CollectionDefinition(Name)] +public class Sena3Collection : ICollectionFixture +{ + public const string Name = nameof(Sena3Collection); +} diff --git a/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj b/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj index d234fd79a9..13a323d3fa 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj +++ b/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj @@ -5,6 +5,7 @@ $(MSBuildProjectDirectory) + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs index 1b6a71f1ea..c3f1aa6b07 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs @@ -13,7 +13,8 @@ namespace FwLiteProjectSync.Tests; [Trait("Category", "Integration")] -public class Sena3SyncTests : IClassFixture, IAsyncLifetime +[Collection(Sena3Collection.Name)] +public class Sena3SyncTests : IAsyncLifetime { private readonly Sena3Fixture _fixture; private CrdtFwdataProjectSyncService _syncService = null!; diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs new file mode 100644 index 0000000000..a1c8a0b38c --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs @@ -0,0 +1,91 @@ +using BenchmarkDotNet.Attributes; +#if !DEBUG +using BenchmarkDotNet.Running; +using FluentAssertions.Execution; +#endif +using FwLiteProjectSync.Tests.Fixtures; +using LexCore.Sync; +using Microsoft.Extensions.DependencyInjection; +using MiniLcm; +using Xunit.Abstractions; + +namespace FwLiteProjectSync.Tests; + +/// +/// Times a first sync of Sena-3 from an empty CRDT (sync, not import — we save an empty +/// snapshot first so the path runs as a real sync). +/// +[Trait("Category", "Integration")] +[Trait("Category", "Benchmark")] +[Collection(Sena3Collection.Name)] +public class SyncBenchmark(Sena3Fixture fixture, ITestOutputHelper output) +{ + [Fact] + public void First_Sync_Sena3() + { + FirstSyncBench.Fixture = fixture; +#if DEBUG + // Debug timings are unreliable (no JIT optimizations + BDN harness flags Debug builds). + // Run once for code-path coverage; thresholds enforced in Release only (CI benchmark job). + output.WriteLine("Debug build: running once for coverage; threshold enforced in Release only."); + var bench = new FirstSyncBench(); + bench.IterationSetup(); + try { _ = bench.SyncFromEmpty(); } + finally { bench.IterationCleanup(); } +#else + using var scope = new AssertionScope(); + var summary = BenchmarkRunner.Run(BenchmarkSupport.ConfigFor(output)); + BenchmarkSupport.AssertRunWasSuccessful(summary); + + var report = summary.Reports.Single(); + var meanSeconds = report.ResultStatistics!.Mean / 1_000_000_000.0; + output.WriteLine($"first-sync mean = {meanSeconds:F2}s (bound={FirstSyncBench.ThresholdSeconds:F2}s)"); + + meanSeconds.Should().BeLessThan(FirstSyncBench.ThresholdSeconds, + $"first-sync should not regress past its threshold — see {nameof(FirstSyncBench)}.{nameof(FirstSyncBench.ThresholdSeconds)}"); +#endif + } +} + +// BenchmarkDotNet has no async support. .Result is intentional and will not deadlock. +#pragma warning disable VSTHRD002 + +public class FirstSyncBench +{ + // CI 2026-05-06: mean 49.5s, StdDev 2.4s (medium variance) => 57s (~3σ above mean) + public const double ThresholdSeconds = 57.0; + + internal static Sena3Fixture Fixture = null!; + + private TestProject _project = null!; + private ProjectSnapshot _projectSnapshot = null!; + private CrdtFwdataProjectSyncService _syncService = null!; + + [IterationSetup] + public void IterationSetup() + { + _project = Fixture.SetupProjects().GetAwaiter().GetResult(); + _syncService = _project.Services.GetRequiredService(); + + // Save an empty snapshot so the first sync runs as Sync, not Import. + ProjectSnapshotService.SaveProjectSnapshot(_project.FwDataProject, ProjectSnapshot.Empty) + .GetAwaiter().GetResult(); + _projectSnapshot = _project.Services.GetRequiredService() + .GetProjectSnapshot(_project.FwDataProject).GetAwaiter().GetResult() + ?? throw new InvalidOperationException("Expected snapshot to exist after saving"); + } + + [Benchmark] + public SyncResult SyncFromEmpty() + { + return _syncService.Sync(_project.CrdtApi, _project.FwDataApi, _projectSnapshot).Result; + } + + [IterationCleanup] + public void IterationCleanup() + { + _project?.Dispose(); + _project = null!; + } +} +#pragma warning restore VSTHRD002 diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs new file mode 100644 index 0000000000..e2a9161109 --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs @@ -0,0 +1,250 @@ +using BenchmarkDotNet.Attributes; +#if !DEBUG +using BenchmarkDotNet.Running; +using FluentAssertions.Execution; +#endif +using FwLiteProjectSync.Tests.Fixtures; +using LexCore.Sync; +using Microsoft.Extensions.DependencyInjection; +using MiniLcm; +using MiniLcm.Models; +using MiniLcm.SyncHelpers; +using Soenneker.Utils.AutoBogus; +using Xunit.Abstractions; + +namespace FwLiteProjectSync.Tests; + +/// +/// Times sync after applying a profile-specific batch of mutations to the FwData side of +/// a fully-imported Sena-3 project. Each profile stresses a different change kind. +/// +[Trait("Category", "Integration")] +[Trait("Category", "Benchmark")] +[Collection(Sena3Collection.Name)] +public class SyncMutationBenchmark(Sena3Fixture fixture, ITestOutputHelper output) +{ + [Fact] + public void Sync_AfterMutations_Sena3() + { + MutationSyncBench.Fixture = fixture; +#if DEBUG + // Debug timings are unreliable (no JIT optimizations + BDN harness flags Debug builds). + // Run each profile once for code-path coverage; thresholds enforced in Release only (CI benchmark job). + output.WriteLine("Debug build: running each profile once for coverage; thresholds enforced in Release only."); + var bench = new MutationSyncBench(); + foreach (var profile in MutationSyncBench.ThresholdSecondsByProfile.Keys) + { + bench.Profile = profile; + bench.IterationSetup(); + try { _ = bench.SyncAfterMutations(); } + finally { bench.IterationCleanup(); } + } +#else + using var scope = new AssertionScope(); + var summary = BenchmarkRunner.Run(BenchmarkSupport.ConfigFor(output)); + BenchmarkSupport.AssertRunWasSuccessful(summary); + + foreach (var report in summary.Reports) + { + // ResultStatistics is guaranteed non-null by AssertRunWasSuccessful, but the scope + // keeps going on failure so we still need a defensive null-check before dereferencing. + if (report.ResultStatistics is null) continue; + + var profile = report.BenchmarkCase.Parameters["Profile"]?.ToString() + ?? throw new InvalidOperationException("Expected Profile parameter on benchmark case"); + var meanSeconds = report.ResultStatistics.Mean / 1_000_000_000.0; + var bound = MutationSyncBench.ThresholdSecondsByProfile[profile]; + output.WriteLine($"{profile} mean = {meanSeconds:F2}s (bound={bound:F2}s)"); + + meanSeconds.Should().BeLessThan(bound, + $"profile {profile} should not regress past its threshold — see {nameof(MutationSyncBench)}.{nameof(MutationSyncBench.ThresholdSecondsByProfile)}"); + } +#endif + } +} + +#pragma warning disable VSTHRD002 + +public class MutationSyncBench +{ + public static readonly IReadOnlyDictionary ThresholdSecondsByProfile = new Dictionary + { + // CI 2026-05-06: mean 58.3s, StdDev 4.1s (high variance) => 72s (~4σ above mean) + ["component-heavy"] = 72.0, + // CI 2026-05-06: mean 94.3s, StdDev 5.7s (high variance) => 115s (~4σ above mean) + ["delete-heavy"] = 115.0, + // CI 2026-05-06: mean 36.2s, StdDev 3.3s (medium variance) => 45s (~3σ above mean) + ["mixed-realistic"] = 45.0, + // CI 2026-05-06: mean 5.05s, StdDev 0.4s (low variance) => 7s (generous margin since it's already pretty fast and we want to avoid false positives from noise). + ["patch-heavy"] = 7.0, + // CI 2026-05-06: mean 0.77s, StdDev 0.2s (low variance) => 3s (super fast, so meh) + ["reorder-heavy"] = 3.0, + }; + + public static IEnumerable Profiles => ThresholdSecondsByProfile.Keys; + + [ParamsSource(nameof(Profiles))] + public string Profile { get; set; } = ""; + + private const int MutationCount = 400; + + private static readonly AutoFaker AutoFaker = new(); + + internal static Sena3Fixture Fixture = null!; + + private TestProject _project = null!; + private ProjectSnapshot _projectSnapshot = null!; + private CrdtFwdataProjectSyncService _syncService = null!; + + [IterationSetup] + public void IterationSetup() + { + _project = Fixture.SetupProjects().GetAwaiter().GetResult(); + var services = _project.Services; + _syncService = services.GetRequiredService(); + var snapshotService = services.GetRequiredService(); + + _syncService.Import(_project.CrdtApi, _project.FwDataApi).GetAwaiter().GetResult(); + snapshotService.RegenerateProjectSnapshot(_project.CrdtApi, _project.FwDataProject, keepBackup: false) + .GetAwaiter().GetResult(); + _projectSnapshot = snapshotService.GetProjectSnapshot(_project.FwDataProject).GetAwaiter().GetResult() + ?? throw new InvalidOperationException("Expected snapshot to exist after regeneration"); + + ApplyProfile(Profile, _project.FwDataApi, MutationCount).GetAwaiter().GetResult(); + } + + [Benchmark] + public SyncResult SyncAfterMutations() + { + return _syncService.Sync(_project.CrdtApi, _project.FwDataApi, _projectSnapshot).Result; + } + + [IterationCleanup] + public void IterationCleanup() + { + _project?.Dispose(); + _project = null!; + } + + private static async Task ApplyProfile(string profile, IMiniLcmApi api, int count) + { + var allEntries = await api.GetAllEntries().ToListAsync(); + var shuffled = AutoFaker.Faker.Random.Shuffle(allEntries).ToList(); + + switch (profile) + { + case "delete-heavy": + await DeleteEntries(api, shuffled.Take(count)); + break; + case "patch-heavy": + await PatchLexemes(api, shuffled.Take(count)); + break; + case "reorder-heavy": + await ReorderSenses(api, shuffled.Take(count)); + break; + case "component-heavy": + await AndCompleteFormComponents(api, shuffled, count); + break; + case "mixed-realistic": + await MixedRealistic(api, shuffled, count); + break; + default: + throw new ArgumentException($"Unknown profile: {profile}", nameof(profile)); + } + } + + // Disjoint slices so each kind of mutation operates on a different set of entries. + private static async Task MixedRealistic(IMiniLcmApi api, List entries, int totalCount) + { + var per = totalCount / 4; + await DeleteEntries(api, entries.Take(per)); + await PatchLexemes(api, entries.Skip(per).Take(per)); + await ReorderSenses(api, entries.Skip(per * 2).Take(per)); + await AndCompleteFormComponents(api, [.. entries.Skip(per * 3)], per); + } + + private static async Task DeleteEntries(IMiniLcmApi api, IEnumerable entries) + { + foreach (var entry in entries) + await api.DeleteEntry(entry.Id); + } + + private static async Task PatchLexemes(IMiniLcmApi api, IEnumerable entries) + { + foreach (var entry in entries) + { + var after = entry.Copy(); + // Mutate the first existing lexeme WS, or fall back to citation. + if (after.LexemeForm.Values.Count > 0) + { + var (wsId, val) = after.LexemeForm.Values.First(); + after.LexemeForm[wsId] = val + "_mut"; + } + else if (after.CitationForm.Values.Count > 0) + { + var (wsId, val) = after.CitationForm.Values.First(); + after.CitationForm[wsId] = val + "_mut"; + } + else + { + continue; + } + await api.UpdateEntry(entry, after); + } + } + + private static async Task ReorderSenses(IMiniLcmApi api, IEnumerable entries) + { + // Only entries with 2+ senses can be reordered; move the first sense to the end. + foreach (var entry in entries.Where(e => e.Senses.Count >= 2)) + { + var first = entry.Senses[0]; + var last = entry.Senses[^1]; + if (first.Id == last.Id) continue; + await api.MoveSense(entry.Id, first.Id, new BetweenPosition(last.Id, null)); + } + } + + private static async Task AndCompleteFormComponents(IMiniLcmApi api, List pool, int targetCount) + { + var attempted = new HashSet<(Guid, Guid)>( + pool.SelectMany(e => e.Components.Select(c => (c.ComplexFormEntryId, c.ComponentEntryId)))); + + var applied = 0; + var attempts = 0; + var maxAttempts = targetCount * 10; + while (applied < targetCount && attempts < maxAttempts) + { + attempts++; + var cfIdx = AutoFaker.Faker.Random.Int(0, pool.Count - 1); + var compIdx = AutoFaker.Faker.Random.Int(0, pool.Count - 1); + if (cfIdx == compIdx) continue; + var cf = pool[cfIdx]; + var comp = pool[compIdx]; + if (!attempted.Add((cf.Id, comp.Id))) continue; + + try + { + await api.CreateComplexFormComponent(new ComplexFormComponent + { + Id = Guid.NewGuid(), + ComplexFormEntryId = cf.Id, + ComponentEntryId = comp.Id, + ComponentSenseId = comp.Senses.Any() ? AutoFaker.Faker.PickRandom(comp.Senses).Id : null, + }); + applied++; + } + catch + { + // Some components are invalid. Just skip and try another. + } + } + + if (applied < targetCount) + { + throw new InvalidOperationException( + $"Failed to apply {targetCount} complex form links after {attempts} attempts; only applied {applied}"); + } + } +} +#pragma warning restore VSTHRD002 diff --git a/backend/FwLite/FwLiteProjectSync.Tests/XUnitBenchmarkLogger.cs b/backend/FwLite/FwLiteProjectSync.Tests/XUnitBenchmarkLogger.cs new file mode 100644 index 0000000000..3657394513 --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync.Tests/XUnitBenchmarkLogger.cs @@ -0,0 +1,40 @@ +using System.Text; +using BenchmarkDotNet.Loggers; +using Xunit.Abstractions; + +namespace FwLiteProjectSync.Tests; + +/// +/// Bridges BenchmarkDotNet's line-buffered logger calls to xUnit's . +/// BDN writes one line via several calls followed by ; +/// we accumulate fragments in and flush on each line break. +/// +internal sealed class XUnitBenchmarkLogger(ITestOutputHelper output) : ILogger +{ + public string Id => nameof(XUnitBenchmarkLogger); + public int Priority => 0; + private readonly StringBuilder _line = new(); + + public void Write(LogKind logKind, string text) + { + _line.Append(text); + } + + public void WriteLine() + { + output.WriteLine(_line.ToString()); + _line.Clear(); + } + + public void WriteLine(LogKind logKind, string text) + { + _line.Append(text); + WriteLine(); + } + + public void Flush() + { + if (_line.Length == 0) return; + WriteLine(); + } +} From 3d4ed18f1a8be038602a3f04c1227ac101f8bad3 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 6 May 2026 17:09:02 +0200 Subject: [PATCH 02/11] Add commits order index --- ...506150734_AddCommitsOrderIndex.Designer.cs | 783 ++++++++++++++++++ .../20260506150734_AddCommitsOrderIndex.cs | 31 + 2 files changed, 814 insertions(+) create mode 100644 backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.Designer.cs create mode 100644 backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.cs diff --git a/backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.Designer.cs b/backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.Designer.cs new file mode 100644 index 0000000000..b1a7a659e1 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.Designer.cs @@ -0,0 +1,783 @@ +// +using System; +using System.Collections.Generic; +using LcmCrdt; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + [DbContext(typeof(LcmCrdtDbContext))] + [Migration("20260506150734_AddCommitsOrderIndex")] + partial class AddCommitsOrderIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("LcmCrdt.FullTextSearch.EntrySearchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CitationForm") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Gloss") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Headword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LexemeForm") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("EntrySearchRecord", null, t => + { + t.ExcludeFromMigrations(); + }); + }); + + modelBuilder.Entity("LcmCrdt.ProjectData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FwProjectId") + .HasColumnType("TEXT"); + + b.Property("LastUserId") + .HasColumnType("TEXT"); + + b.Property("LastUserName") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginDomain") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Editor"); + + b.HasKey("Id"); + + b.ToTable("ProjectData"); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ComplexFormEntryId") + .HasColumnType("TEXT"); + + b.Property("ComplexFormHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentEntryId") + .HasColumnType("TEXT"); + + b.Property("ComponentHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentSenseId") + .HasColumnType("TEXT") + .HasColumnName("ComponentSenseId"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentEntryId"); + + b.HasIndex("ComponentSenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId") + .IsUnique() + .HasFilter("ComponentSenseId IS NULL"); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId", "ComponentSenseId") + .IsUnique() + .HasFilter("ComponentSenseId IS NOT NULL"); + + b.ToTable("ComplexFormComponents", (string)null); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ComplexFormType"); + }); + + modelBuilder.Entity("MiniLcm.Models.CustomView", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Analysis") + .HasColumnType("jsonb"); + + b.Property("Base") + .HasColumnType("INTEGER"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("EntryFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ExampleFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SenseFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Vernacular") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("CustomView"); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CitationForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ComplexFormTypes") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LexemeForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("LiteralMeaning") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("MorphType") + .HasColumnType("INTEGER"); + + b.Property("Note") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PublishIn") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Entry"); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("Reference") + .HasColumnType("jsonb"); + + b.Property("SenseId") + .HasColumnType("TEXT"); + + b.Property("Sentence") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Translations") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ExampleSentence"); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Publication"); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("SemanticDomain"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Gloss") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("PartOfSpeechId") + .HasColumnType("TEXT"); + + b.Property("SemanticDomains") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntryId"); + + b.HasIndex("PartOfSpeechId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Sense"); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Exemplars") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Font") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WsId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("WsId", "Type") + .IsUnique(); + + b.ToTable("WritingSystem"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ParentHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.ComplexProperty>("HybridDateTime", "SIL.Harmony.Commit.HybridDateTime#HybridDateTime", b1 => + { + b1.IsRequired(); + + b1.Property("Counter") + .HasColumnType("INTEGER") + .HasColumnName("Counter"); + + b1.Property("DateTime") + .HasColumnType("TEXT") + .HasColumnName("DateTime"); + }); + + b.HasKey("Id"); + + b.ToTable("Commits", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Change") + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.HasKey("CommitId", "Index"); + + b.ToTable("ChangeEntities", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Entity") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityIsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsRoot") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("References") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntityId"); + + b.HasIndex("CommitId", "EntityId") + .IsUnique(); + + b.ToTable("Snapshots", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.LocalResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LocalResource"); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.RemoteResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("RemoteId") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("RemoteResource"); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Components") + .HasForeignKey("ComplexFormEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("ComplexForms") + .HasForeignKey("ComponentEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany() + .HasForeignKey("ComponentSenseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormComponent", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormType", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.CustomView", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.CustomView", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Entry", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany("ExampleSentences") + .HasForeignKey("SenseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ExampleSentence", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.PartOfSpeech", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Publication", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.SemanticDomain", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Senses") + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.PartOfSpeech", "PartOfSpeech") + .WithMany() + .HasForeignKey("PartOfSpeechId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Sense", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.WritingSystem", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.HasOne("SIL.Harmony.Commit", null) + .WithMany("ChangeEntities") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.HasOne("SIL.Harmony.Commit", "Commit") + .WithMany("Snapshots") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Commit"); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.RemoteResource", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("SIL.Harmony.Resource.RemoteResource", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Navigation("ComplexForms"); + + b.Navigation("Components"); + + b.Navigation("Senses"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Navigation("ExampleSentences"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Navigation("ChangeEntities"); + + b.Navigation("Snapshots"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.cs b/backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.cs new file mode 100644 index 0000000000..cbb4689e12 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + /// + public partial class AddCommitsOrderIndex : Migration + { + /// + /// Name of the compound index over (DateTime, Counter, Id) on the Commits table. + /// + public const string CommitsOrderIndexName = "IX_Commits_DateTime_Counter_Id"; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: CommitsOrderIndexName, + table: "Commits", + columns: ["DateTime", "Counter", "Id"], + descending: [true, true, true]); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex(name: CommitsOrderIndexName, table: "Commits"); + } + } +} From 91b75d618b8c1e9233a782dde3a83fa8dc0abf33 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 6 May 2026 17:10:34 +0200 Subject: [PATCH 03/11] Trigger benchmark failures --- .../FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs | 2 +- .../FwLiteProjectSync.Tests/SyncMutationBenchmark.cs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs index a1c8a0b38c..896d3c1f5c 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs @@ -53,7 +53,7 @@ public void First_Sync_Sena3() public class FirstSyncBench { // CI 2026-05-06: mean 49.5s, StdDev 2.4s (medium variance) => 57s (~3σ above mean) - public const double ThresholdSeconds = 57.0; + public const double ThresholdSeconds = 1;//57.0; internal static Sena3Fixture Fixture = null!; diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs index e2a9161109..f680c29185 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs @@ -70,15 +70,15 @@ public class MutationSyncBench public static readonly IReadOnlyDictionary ThresholdSecondsByProfile = new Dictionary { // CI 2026-05-06: mean 58.3s, StdDev 4.1s (high variance) => 72s (~4σ above mean) - ["component-heavy"] = 72.0, + ["component-heavy"] = 1,//72.0, // CI 2026-05-06: mean 94.3s, StdDev 5.7s (high variance) => 115s (~4σ above mean) - ["delete-heavy"] = 115.0, + ["delete-heavy"] = 1,//115.0, // CI 2026-05-06: mean 36.2s, StdDev 3.3s (medium variance) => 45s (~3σ above mean) - ["mixed-realistic"] = 45.0, + ["mixed-realistic"] = 1,//45.0, // CI 2026-05-06: mean 5.05s, StdDev 0.4s (low variance) => 7s (generous margin since it's already pretty fast and we want to avoid false positives from noise). - ["patch-heavy"] = 7.0, + ["patch-heavy"] = 1,//7.0, // CI 2026-05-06: mean 0.77s, StdDev 0.2s (low variance) => 3s (super fast, so meh) - ["reorder-heavy"] = 3.0, + ["reorder-heavy"] = 1,//3.0, }; public static IEnumerable Profiles => ThresholdSecondsByProfile.Keys; From 5b5d6fb83e3c41976933b300b394bc5c50d85aa6 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 7 May 2026 08:16:13 +0200 Subject: [PATCH 04/11] Upload benchmark results --- .github/workflows/fw-lite.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index 792ff2336f..5959dd0881 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -88,6 +88,14 @@ jobs: - name: Dotnet test (benchmarks) run: dotnet test backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj -c Release --logger GitHubActions --no-build --filter "Category=Benchmark" + - name: Upload benchmark results + if: always() + uses: actions/upload-artifact@v4 + with: + name: benchmark-results + path: '**/BenchmarkDotNet.Artifacts/**' + if-no-files-found: warn + frontend: runs-on: ubuntu-latest steps: From b823df4fde790ba87186a90a3775bc610ffcb2a4 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 7 May 2026 16:19:07 +0200 Subject: [PATCH 05/11] Update benchmark thresholds for new db index --- .../FwLiteProjectSync.Tests/SyncBenchmark.cs | 5 ++-- .../SyncMutationBenchmark.cs | 25 +++++++++++-------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs index 896d3c1f5c..537a3c58ae 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs @@ -52,8 +52,9 @@ public void First_Sync_Sena3() public class FirstSyncBench { - // CI 2026-05-06: mean 49.5s, StdDev 2.4s (medium variance) => 57s (~3σ above mean) - public const double ThresholdSeconds = 1;//57.0; + // CI initial result: mean 49.5s, StdDev 2.4s (medium variance) => 57s (~3σ above mean) + // CI with commits order index: mean 44.5s, StdDev 3.5s (medium variance) => 55s (~3σ above mean) + public const double ThresholdSeconds = 55.0; internal static Sena3Fixture Fixture = null!; diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs index f680c29185..0438ec4966 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs @@ -69,16 +69,21 @@ public class MutationSyncBench { public static readonly IReadOnlyDictionary ThresholdSecondsByProfile = new Dictionary { - // CI 2026-05-06: mean 58.3s, StdDev 4.1s (high variance) => 72s (~4σ above mean) - ["component-heavy"] = 1,//72.0, - // CI 2026-05-06: mean 94.3s, StdDev 5.7s (high variance) => 115s (~4σ above mean) - ["delete-heavy"] = 1,//115.0, - // CI 2026-05-06: mean 36.2s, StdDev 3.3s (medium variance) => 45s (~3σ above mean) - ["mixed-realistic"] = 1,//45.0, - // CI 2026-05-06: mean 5.05s, StdDev 0.4s (low variance) => 7s (generous margin since it's already pretty fast and we want to avoid false positives from noise). - ["patch-heavy"] = 1,//7.0, - // CI 2026-05-06: mean 0.77s, StdDev 0.2s (low variance) => 3s (super fast, so meh) - ["reorder-heavy"] = 1,//3.0, + // CI 2026-05-06: mean 58.3s, StdDev 4.1s (high variance) => 72s (~4σ above mean) + // CI with commits order index: mean 46.9s, StdDev 2.2s (medium variance) => 53s (~3σ above mean) + ["component-heavy"] = 53.0, + // CI 2026-05-06: mean 94.3s, StdDev 5.7s (high variance) => 115s (~4σ above mean) + // CI with commits order index: mean 91.6s, StdDev 4.3s (high variance) => 105s (~4σ above mean) + ["delete-heavy"] = 105.0, + // CI 2026-05-06: mean 36.2s, StdDev 3.3s (medium variance) => 45s (~3σ above mean) + // CI with commits order index: mean 33.4s, StdDev 0.8s (low variance) => 36s (~3σ above mean) + ["mixed-realistic"] = 36.0, + // CI 2026-05-06: mean 5.05s, StdDev 0.4s (low variance) => 7s (generous margin since it's already pretty fast and we want to avoid false positives from noise). + // CI with commits order index: mean 3.7s, StdDev 0.1s (low variance) => 5s (pretty fast, so meh) + ["patch-heavy"] = 5.0, + // CI 2026-05-06: mean 0.77s, StdDev 0.2s (low variance) => 3s (super fast, so meh) + // CI with commits order index: mean 0.58s, StdDev 0.02s (low variance) => 2s (super fast, so meh) + ["reorder-heavy"] = 2.0, }; public static IEnumerable Profiles => ThresholdSecondsByProfile.Keys; From c9095d66a5847f9abe41b0548ac2ef599069734c Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 7 May 2026 17:57:55 +0200 Subject: [PATCH 06/11] Refactor index migration --- .../20260506150734_AddCommitsOrderIndex.cs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.cs b/backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.cs index cbb4689e12..0c19509e2d 100644 --- a/backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.cs +++ b/backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.cs @@ -1,22 +1,20 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace LcmCrdt.Migrations { /// + // Backs harmony's default commit ordering. Hand-written because the index spans a + // ComplexProperty + regular property (efcore#11336). Perhaps move to harmony's fluent API + // via EFCore.ComplexIndexes when we're on .NET 10. public partial class AddCommitsOrderIndex : Migration { - /// - /// Name of the compound index over (DateTime, Counter, Id) on the Commits table. - /// - public const string CommitsOrderIndexName = "IX_Commits_DateTime_Counter_Id"; - /// protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateIndex( - name: CommitsOrderIndexName, + name: "IX_Commits_DateTime_Counter_Id", table: "Commits", columns: ["DateTime", "Counter", "Id"], descending: [true, true, true]); @@ -25,7 +23,7 @@ protected override void Up(MigrationBuilder migrationBuilder) /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropIndex(name: CommitsOrderIndexName, table: "Commits"); + migrationBuilder.DropIndex(name: "IX_Commits_DateTime_Counter_Id", table: "Commits"); } } } From b6cdca7812dd6c500738684897699a361a8c77d0 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 18 May 2026 17:54:37 +0200 Subject: [PATCH 07/11] Bump benchmark job to .NET 10 --- .github/workflows/fw-lite.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index 5959dd0881..3104e6d15b 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -80,7 +80,7 @@ jobs: submodules: true - uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.x' + dotnet-version: '10.x' - name: Dotnet build (Release) run: dotnet build backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj -c Release From da3929a377c89b21f29208615e18f112ce3fd9bc Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 18 May 2026 17:54:45 +0200 Subject: [PATCH 08/11] Bump BenchmarkDotNet 0.14.0 -> 0.15.8 --- backend/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 805cfc9e14..ceefa71268 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -9,7 +9,7 @@ - + From 53d6f8ef5884086170154c22d26da7cc8fd19b8b Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 19 May 2026 11:11:10 +0200 Subject: [PATCH 09/11] Switch benchmarks to warmup config; re-derive thresholds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Monitoring strategy with WarmupCount=1, IterationCount=4 — same total work as before, but iteration 1's JIT/EF-model-build/cold-cache cost is discarded instead of folded into the measurement, so StdDev tightens and thresholds can come down to genuine 3σ above the with-index mean. Both comment lines (baseline + with-index) are re-measured under this config so the speedup comparison is apples-to-apples. --- .../BenchmarkSupport.cs | 15 ++++++++--- .../FwLiteProjectSync.Tests/SyncBenchmark.cs | 6 ++--- .../SyncMutationBenchmark.cs | 26 +++++++++---------- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/BenchmarkSupport.cs b/backend/FwLite/FwLiteProjectSync.Tests/BenchmarkSupport.cs index 5ea983706f..7a9eb7aae4 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/BenchmarkSupport.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/BenchmarkSupport.cs @@ -13,8 +13,9 @@ namespace FwLiteProjectSync.Tests; internal static class BenchmarkSupport { /// - /// Standard config for sync benchmarks: in-process toolchain, cold iterations, and a logger - /// that pipes the BDN summary table into xUnit's test output. + /// Standard config for sync benchmarks: in-process toolchain, one warmup + four measurement + /// iterations (Monitoring strategy), and a logger that pipes the BDN summary table into + /// xUnit's test output. /// /// /// The in-process toolchain is what lets a benchmark class read a static field set by its @@ -25,14 +26,20 @@ internal static class BenchmarkSupport /// for multi-iteration sync benchmarks (e.g. delete-heavy alone is ~80s/iter and full-import /// setup adds ~50s/iter, so 5 iterations need ~11 min). 30 min covers the worst case with /// headroom. + /// + /// Monitoring + WarmupCount=1 means iteration 1 absorbs JIT + EF model build + first-touch + /// file cache and is discarded; iterations 2-5 are measured. ColdStart would skip warmup + /// entirely; for these slow ops the JIT/model-build noise in iteration 1 is large enough + /// that one discarded warmup tightens StdDev meaningfully without changing total CI time. /// public static IConfig ConfigFor(ITestOutputHelper output) { var toolchain = new InProcessNoEmitToolchain(TimeSpan.FromMinutes(30), logOutput: false); return ManualConfig.CreateEmpty() .AddJob(Job.Default - .WithStrategy(RunStrategy.ColdStart) - .WithIterationCount(5) + .WithStrategy(RunStrategy.Monitoring) + .WithWarmupCount(1) + .WithIterationCount(4) .WithToolchain(toolchain)) .AddExporter(JsonExporter.FullCompressed) .AddColumnProvider(DefaultColumnProviders.Instance) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs index 537a3c58ae..5b5d03cb80 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs @@ -52,9 +52,9 @@ public void First_Sync_Sena3() public class FirstSyncBench { - // CI initial result: mean 49.5s, StdDev 2.4s (medium variance) => 57s (~3σ above mean) - // CI with commits order index: mean 44.5s, StdDev 3.5s (medium variance) => 55s (~3σ above mean) - public const double ThresholdSeconds = 55.0; + // CI baseline (no index): mean 52.34s, StdDev 0.27s (low variance) => 53s (~3σ above mean) + // CI with commits order index: mean 44.35s, StdDev 1.10s (low variance) => 50s (~5σ above mean, generous for run-to-run drift) + public const double ThresholdSeconds = 50.0; internal static Sena3Fixture Fixture = null!; diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs index 0438ec4966..6a7bab022d 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs @@ -69,20 +69,20 @@ public class MutationSyncBench { public static readonly IReadOnlyDictionary ThresholdSecondsByProfile = new Dictionary { - // CI 2026-05-06: mean 58.3s, StdDev 4.1s (high variance) => 72s (~4σ above mean) - // CI with commits order index: mean 46.9s, StdDev 2.2s (medium variance) => 53s (~3σ above mean) - ["component-heavy"] = 53.0, - // CI 2026-05-06: mean 94.3s, StdDev 5.7s (high variance) => 115s (~4σ above mean) - // CI with commits order index: mean 91.6s, StdDev 4.3s (high variance) => 105s (~4σ above mean) - ["delete-heavy"] = 105.0, - // CI 2026-05-06: mean 36.2s, StdDev 3.3s (medium variance) => 45s (~3σ above mean) - // CI with commits order index: mean 33.4s, StdDev 0.8s (low variance) => 36s (~3σ above mean) - ["mixed-realistic"] = 36.0, - // CI 2026-05-06: mean 5.05s, StdDev 0.4s (low variance) => 7s (generous margin since it's already pretty fast and we want to avoid false positives from noise). - // CI with commits order index: mean 3.7s, StdDev 0.1s (low variance) => 5s (pretty fast, so meh) + // CI baseline (no index): mean 52.40s, StdDev 1.51s (low variance) => 61s (~5σ above mean) + // CI with commits order index: mean 50.49s, StdDev 0.87s (low variance) => 61s (~12σ above mean — kept generous, run-to-run drift can be ~3s here) + ["component-heavy"] = 61.0, + // CI baseline (no index): mean 87.02s, StdDev 3.85s (medium variance) => 97s (~3σ above mean) + // CI with commits order index: mean 87.92s, StdDev 1.42s (low variance) => 97s (~6σ above mean — kept generous, run-to-run drift can be ~2s here) + ["delete-heavy"] = 97.0, + // CI baseline (no index): mean 32.99s, StdDev 0.77s (low variance) => 38s (~7σ above mean) + // CI with commits order index: mean 32.95s, StdDev 1.08s (low variance) => 38s (~5σ above mean) + ["mixed-realistic"] = 38.0, + // CI baseline (no index): mean 4.52s, StdDev 0.08s (low variance) => 5s (generous margin since it's already pretty fast and we want to avoid false positives from noise) + // CI with commits order index: mean 3.53s, StdDev 0.10s (low variance) => 5s (pretty fast, so meh — same margin works) ["patch-heavy"] = 5.0, - // CI 2026-05-06: mean 0.77s, StdDev 0.2s (low variance) => 3s (super fast, so meh) - // CI with commits order index: mean 0.58s, StdDev 0.02s (low variance) => 2s (super fast, so meh) + // CI baseline (no index): mean 0.69s, StdDev 0.08s (low variance) => 2s (super fast, so meh) + // CI with commits order index: mean 0.57s, StdDev 0.05s (low variance) => 2s (super fast, so meh) ["reorder-heavy"] = 2.0, }; From a28d3a7b6d131141a4b0849ded7320cfb5cd45c8 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 19 May 2026 13:46:46 +0200 Subject: [PATCH 10/11] Finalize benchmarks --- .../FwLiteProjectSync.Tests/SyncBenchmark.cs | 6 ++-- .../SyncMutationBenchmark.cs | 28 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs index 5b5d03cb80..5959658d99 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs @@ -52,9 +52,9 @@ public void First_Sync_Sena3() public class FirstSyncBench { - // CI baseline (no index): mean 52.34s, StdDev 0.27s (low variance) => 53s (~3σ above mean) - // CI with commits order index: mean 44.35s, StdDev 1.10s (low variance) => 50s (~5σ above mean, generous for run-to-run drift) - public const double ThresholdSeconds = 50.0; + // CI baseline (no index): mean 52.34s, StdDev 0.27s (low variance) => 57s (~10%) + // CI with commits order index: mean 44.35s, StdDev 1.10s (med variance) => 51s (~15%) + public const double ThresholdSeconds = 51.0; internal static Sena3Fixture Fixture = null!; diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs index 6a7bab022d..fb27ec171c 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs @@ -69,21 +69,21 @@ public class MutationSyncBench { public static readonly IReadOnlyDictionary ThresholdSecondsByProfile = new Dictionary { - // CI baseline (no index): mean 52.40s, StdDev 1.51s (low variance) => 61s (~5σ above mean) - // CI with commits order index: mean 50.49s, StdDev 0.87s (low variance) => 61s (~12σ above mean — kept generous, run-to-run drift can be ~3s here) - ["component-heavy"] = 61.0, - // CI baseline (no index): mean 87.02s, StdDev 3.85s (medium variance) => 97s (~3σ above mean) - // CI with commits order index: mean 87.92s, StdDev 1.42s (low variance) => 97s (~6σ above mean — kept generous, run-to-run drift can be ~2s here) - ["delete-heavy"] = 97.0, - // CI baseline (no index): mean 32.99s, StdDev 0.77s (low variance) => 38s (~7σ above mean) - // CI with commits order index: mean 32.95s, StdDev 1.08s (low variance) => 38s (~5σ above mean) - ["mixed-realistic"] = 38.0, - // CI baseline (no index): mean 4.52s, StdDev 0.08s (low variance) => 5s (generous margin since it's already pretty fast and we want to avoid false positives from noise) - // CI with commits order index: mean 3.53s, StdDev 0.10s (low variance) => 5s (pretty fast, so meh — same margin works) - ["patch-heavy"] = 5.0, + // CI baseline (no index): mean 52.40s, StdDev 1.51s (med variance) => 60s (~15%) + // CI with commits order index: mean 50.49s, StdDev 0.87s (low variance) => 55s (~10%) + ["component-heavy"] = 55.0, + // CI baseline (no index): mean 87.02s, StdDev 3.85s (hi variance) => 100s (~15%) + // CI with commits order index: mean 87.92s, StdDev 1.42s (med variance) => 100s (~15%) + ["delete-heavy"] = 100.0, + // CI baseline (no index): mean 32.99s, StdDev 0.77s (low variance) => 36s (~10%) + // CI with commits order index: mean 32.95s, StdDev 1.08s (low variance) => 36s (~10%) + ["mixed-realistic"] = 36.0, + // CI baseline (no index): mean 4.52s, StdDev 0.08s (low variance) => 5s (~10%) + // CI with commits order index: mean 3.53s, StdDev 0.10s (low variance) => 4s (~10%) + ["patch-heavy"] = 4.0, // CI baseline (no index): mean 0.69s, StdDev 0.08s (low variance) => 2s (super fast, so meh) - // CI with commits order index: mean 0.57s, StdDev 0.05s (low variance) => 2s (super fast, so meh) - ["reorder-heavy"] = 2.0, + // CI with commits order index: mean 0.57s, StdDev 0.05s (low variance) => 1.5s (super fast, so meh) + ["reorder-heavy"] = 1.5, }; public static IEnumerable Profiles => ThresholdSecondsByProfile.Keys; From e1a5eacb6e50db126640f93d0a1e78fbf8d1d4ee Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 19 May 2026 14:00:21 +0200 Subject: [PATCH 11/11] Use async [Benchmark] methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BenchmarkDotNet has supported async benchmark methods for a long time — the old comment claiming otherwise was wrong, and the .Result blocking call was unnecessary. IterationSetup stays sync (async support there needs BDN 0.16+). --- .../FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs | 11 ++++++----- .../FwLiteProjectSync.Tests/SyncMutationBenchmark.cs | 8 ++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs index 5959658d99..cc16d3558a 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs @@ -21,7 +21,7 @@ namespace FwLiteProjectSync.Tests; public class SyncBenchmark(Sena3Fixture fixture, ITestOutputHelper output) { [Fact] - public void First_Sync_Sena3() + public async Task First_Sync_Sena3() { FirstSyncBench.Fixture = fixture; #if DEBUG @@ -30,7 +30,7 @@ public void First_Sync_Sena3() output.WriteLine("Debug build: running once for coverage; threshold enforced in Release only."); var bench = new FirstSyncBench(); bench.IterationSetup(); - try { _ = bench.SyncFromEmpty(); } + try { _ = await bench.SyncFromEmpty(); } finally { bench.IterationCleanup(); } #else using var scope = new AssertionScope(); @@ -47,7 +47,8 @@ public void First_Sync_Sena3() } } -// BenchmarkDotNet has no async support. .Result is intentional and will not deadlock. +// BenchmarkDotNet doesn't support async [IterationSetup]/[IterationCleanup] signatures, +// so GetAwaiter().GetResult() below is intentional. #pragma warning disable VSTHRD002 public class FirstSyncBench @@ -77,9 +78,9 @@ public void IterationSetup() } [Benchmark] - public SyncResult SyncFromEmpty() + public async Task SyncFromEmpty() { - return _syncService.Sync(_project.CrdtApi, _project.FwDataApi, _projectSnapshot).Result; + return await _syncService.Sync(_project.CrdtApi, _project.FwDataApi, _projectSnapshot); } [IterationCleanup] diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs index fb27ec171c..a66e905051 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs @@ -24,7 +24,7 @@ namespace FwLiteProjectSync.Tests; public class SyncMutationBenchmark(Sena3Fixture fixture, ITestOutputHelper output) { [Fact] - public void Sync_AfterMutations_Sena3() + public async Task Sync_AfterMutations_Sena3() { MutationSyncBench.Fixture = fixture; #if DEBUG @@ -36,7 +36,7 @@ public void Sync_AfterMutations_Sena3() { bench.Profile = profile; bench.IterationSetup(); - try { _ = bench.SyncAfterMutations(); } + try { _ = await bench.SyncAfterMutations(); } finally { bench.IterationCleanup(); } } #else @@ -119,9 +119,9 @@ public void IterationSetup() } [Benchmark] - public SyncResult SyncAfterMutations() + public async Task SyncAfterMutations() { - return _syncService.Sync(_project.CrdtApi, _project.FwDataApi, _projectSnapshot).Result; + return await _syncService.Sync(_project.CrdtApi, _project.FwDataApi, _projectSnapshot); } [IterationCleanup]