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
36 changes: 35 additions & 1 deletion .github/workflows/fw-lite.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,43 @@
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
# Runs post-merge on develop only — benchmarks are too slow and too variance-prone on shared
# runners to gate every PR. Revisit once they're fast enough for the PR critical path.
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
# ~30 min typical in CI (worst observed ~34) — 60 leaves headroom for hosted-runner variance.
timeout-minutes: 60
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
submodules: true
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: benchmark-results
path: '**/BenchmarkDotNet.Artifacts/**'
# warn, not error: with if: always() a build/crash failure emits no artifacts, and the real
# failure is already red elsewhere — a threshold-assertion failure still writes artifacts first.
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.2" />
<PackageVersion Include="BeaKona.AutoInterfaceGenerator" Version="1.0.46" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="coverlet.collector" Version="10.0.0" />
<PackageVersion Include="CrystalQuartz.AspNetCore" Version="7.3.0" />
<PackageVersion Include="DataAnnotatedModelValidations" Version="10.0.0" />
Expand Down
52 changes: 52 additions & 0 deletions backend/FwLite/FwLiteProjectSync.Tests/BenchmarkSupport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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, Monitoring with one warmup +
/// four measurement iterations, BDN output piped to xUnit.
/// </summary>
/// <remarks>
/// In-process is required so benchmark classes can read static fields set by their xUnit driver
/// (e.g. <c>FirstSyncBench.Fixture</c>) — a separate-process toolchain would lose that reference.
/// The 30-min timeout overrides BDN's 5-min default, which is too short for our slowest profiles.
/// </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); thresholds enforced in Release only.
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
{
// Bound catches large regressions, not tight perf budgets — CI variance is too high for that.
// baseline: ~52.3s
// with index: ~44.4s (real ~15% gain; can drift to ~53s under hosted-runner variance)
public const double ThresholdSeconds = 65.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