Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion .github/workflows/fw-lite.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,39 @@
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: '10.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"

- name: Upload benchmark results
if: always()
uses: actions/upload-artifact@v4
with:
name: benchmark-results
path: '**/BenchmarkDotNet.Artifacts/**'
if-no-files-found: warn

frontend:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand Down
1 change: 1 addition & 0 deletions backend/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<ItemGroup>
<PackageVersion Include="AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL" Version="0.5.1" />
<PackageVersion Include="BeaKona.AutoInterfaceGenerator" Version="1.0.42" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
<PackageVersion Include="CrystalQuartz.AspNetCore" Version="7.2.0-beta" />
<PackageVersion Include="DataAnnotatedModelValidations" Version="6.0.0" />
Expand Down
63 changes: 63 additions & 0 deletions backend/FwLite/FwLiteProjectSync.Tests/BenchmarkSupport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// The in-process toolchain is what lets a benchmark class read a static field set by its
/// xUnit driver (see <c>FirstSyncBench.Fixture</c>) — a separate-process toolchain would
/// fork a clean AppDomain and lose that reference.
///
/// The default <see cref="InProcessNoEmitToolchain"/> 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.
///
/// 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.
/// </remarks>
public static IConfig ConfigFor(ITestOutputHelper output)
{
var toolchain = new InProcessNoEmitToolchain(TimeSpan.FromMinutes(30), logOutput: false);
return ManualConfig.CreateEmpty()
.AddJob(Job.Default
.WithStrategy(RunStrategy.Monitoring)
.WithWarmupCount(1)
.WithIterationCount(4)
.WithToolchain(toolchain))
.AddExporter(JsonExporter.FullCompressed)
.AddColumnProvider(DefaultColumnProviders.Instance)
.AddLogger(new XUnitBenchmarkLogger(output));
}

/// <summary>
/// Asserts the benchmark run produced usable measurements. Call inside an
/// <see cref="AssertionScope"/> so per-report threshold failures still surface.
/// </summary>
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");
}
}
}
13 changes: 13 additions & 0 deletions backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3Collection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace FwLiteProjectSync.Tests.Fixtures;

/// <summary>
/// Groups every test class that uses <see cref="Sena3Fixture"/> 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 <c>./Sena3Fixture/</c>
/// folder during <see cref="Sena3Fixture.InitializeAsync"/>.
/// </summary>
[CollectionDefinition(Name)]
public class Sena3Collection : ICollectionFixture<Sena3Fixture>
{
public const string Name = nameof(Sena3Collection);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<Mercurial4ChorusDestDir>$(MSBuildProjectDirectory)</Mercurial4ChorusDestDir>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" />
<PackageReference Include="coverlet.collector">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
3 changes: 2 additions & 1 deletion backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
namespace FwLiteProjectSync.Tests;

[Trait("Category", "Integration")]
public class Sena3SyncTests : IClassFixture<Sena3Fixture>, IAsyncLifetime
[Collection(Sena3Collection.Name)]
public class Sena3SyncTests : IAsyncLifetime
{
private readonly Sena3Fixture _fixture;
private CrdtFwdataProjectSyncService _syncService = null!;
Expand Down
93 changes: 93 additions & 0 deletions backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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;

/// <summary>
/// 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).
/// </summary>
[Trait("Category", "Integration")]
[Trait("Category", "Benchmark")]
[Collection(Sena3Collection.Name)]
public class SyncBenchmark(Sena3Fixture fixture, ITestOutputHelper output)
{
[Fact]
public async Task 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 { _ = await bench.SyncFromEmpty(); }
finally { bench.IterationCleanup(); }
#else
using var scope = new AssertionScope();
var summary = BenchmarkRunner.Run<FirstSyncBench>(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 doesn't support async [IterationSetup]/[IterationCleanup] signatures,
// so GetAwaiter().GetResult() below is intentional.
#pragma warning disable VSTHRD002

public class FirstSyncBench
{
// 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!;

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<CrdtFwdataProjectSyncService>();

// 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<ProjectSnapshotService>()
.GetProjectSnapshot(_project.FwDataProject).GetAwaiter().GetResult()
?? throw new InvalidOperationException("Expected snapshot to exist after saving");
}

[Benchmark]
public async Task<SyncResult> SyncFromEmpty()
{
return await _syncService.Sync(_project.CrdtApi, _project.FwDataApi, _projectSnapshot);
}

[IterationCleanup]
public void IterationCleanup()
{
_project?.Dispose();
_project = null!;
}
}
#pragma warning restore VSTHRD002
Loading
Loading