Skip to content

Commit 6709abd

Browse files
cdhermannclaude
andcommitted
Add .NET 10 / ASP.NET Core 10 implementation and perf-lab integration
Adds a dotnet10/ implementation equivalent to the existing Quarkus 3 and Spring Boot implementations, with full perf-lab, CI, and script integration. Application (dotnet10/): - ASP.NET Core 10 Minimal API, EF Core + Npgsql, same domain model and endpoints as the Quarkus implementation - OpenTelemetry traces (10% sampling) + metrics via OTLP, matching Quarkus - System.Text.Json with source-generated context (AOT-safe) - Health check via AddHealthChecks().AddDbContextCheck<T>() - Startup log matching perf-lab logFileStartedRegex pattern - Unit tests (xUnit) and E2E tests (Testcontainers, excluded from CI pending a database service container) perf-lab integration: - main.yml: dotnet10 RUNTIMECMD with updateScript: skip-version-update; runCmd uses env KEY=value prefix so exec $RUN_CMD correctly handles env-var assignments vs. the binary name - run-benchmarks.sh: dotnet10 in ALLOWED_RUNTIMES/DEFAULT_RUNTIMES; xmx_to_dotnet_gc_limit() converts --jvm-memory to hex DOTNET_GCHeapHardLimit so memory constraints are equivalent to the JVM -Xmx flag - Runtime constraints mirror JVM: CPU pinning via taskset (APP_CMD_PREFIX), DOTNET_GCHeapHardLimit from --jvm-memory, DOTNET_ProcessorCount from --cpus-app, DOTNET_gcServer=1 - helpers/dotnet.yml + requirements.yml: ensure-dotnet requirement check - scripts/time-to-1st-request.sh: fixed exec $RUN_CMD word-splitting for env-var-prefixed commands via env binary prefix CI (.github/workflows/main.yml): - New dotnet-build-test job (builds + unit tests; E2E excluded) - Java 21 setup included because Hyperfoil (used by the test harness) requires it Scripts: - stress.sh and 1strequest.sh detect dotnet binaries and set correct runtime env vars; 1strequest.sh gains --no-purge flag for local dev use - scripts/remote-setup.sh: one-command preparation of a bare Linux benchmark host Docs: - DOTNET-PORTING.md: Quarkus->dotnet concept mapping, implementation status, native AOT blocker rationale, checklist for adding future dotnet versions - README.md: dotnet10 entry, remote benchmark workflow, script examples Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2e28643 commit 6709abd

40 files changed

Lines changed: 1940 additions & 15 deletions

.github/workflows/main.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,39 @@ jobs:
186186
run: |
187187
../scripts/stress.sh "${{ matrix.app.app-jar }}"
188188
../scripts/1strequest.sh "java -XX:ActiveProcessorCount=4 -Xms512m -Xmx512m ${{ matrix.app.run-args }} -jar ${{ matrix.app.app-jar }}" 3
189+
190+
191+
dotnet-build-test:
192+
runs-on: ubuntu-latest
193+
name: "[dotnet-build-test]"
194+
steps:
195+
- uses: actions/checkout@v6
196+
197+
- name: Set up Java 21
198+
uses: actions/setup-java@v5
199+
with:
200+
distribution: temurin
201+
java-version: '21'
202+
203+
- name: Set up .NET 10
204+
uses: actions/setup-dotnet@v4
205+
with:
206+
dotnet-version: '10.x'
207+
208+
- name: Build and test (unit tests only)
209+
working-directory: dotnet10
210+
# E2E tests require a running PostgreSQL instance with seed data.
211+
# See DOTNET-PORTING.md for options to enable them in CI.
212+
run: dotnet test --filter "FullyQualifiedName!~Dotnet10.Tests.E2e"
213+
214+
- name: Publish
215+
working-directory: dotnet10
216+
run: dotnet publish Dotnet10 -c Release -o publish
217+
218+
- name: Setup JBang
219+
uses: jbangdev/setup-jbang@main
220+
221+
- name: Validate simple scripts
222+
run: |
223+
scripts/stress.sh dotnet10/publish/dotnet10
224+
scripts/1strequest.sh "dotnet10/publish/dotnet10" 5

.github/workflows/syncbot.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,7 @@ jobs:
147147
fi
148148
149149
- name: Update PR comment on success
150-
if: steps.push-and-pr.outputs.push_failed_workflows != 'true' && steps.cherry-pick.outputs.skipped_commits == ''
151-
if: steps.cherry-pick.outputs.all_skipped != 'true' && steps.cherry-pick.outputs.skipped_commits == ''
150+
if: steps.push-and-pr.outputs.push_failed_workflows != 'true' && steps.cherry-pick.outputs.all_skipped != 'true' && steps.cherry-pick.outputs.skipped_commits == ''
152151
uses: quarkusio/action-helpers@main
153152
with:
154153
action: maintain-one-comment

DOTNET-PORTING.md

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# dotnet Port — TODO
2+
3+
This file tracks the integration work for the dotnet implementation and serves as the
4+
canonical checklist for adding future dotnet versions.
5+
6+
## Quarkus → dotnet10 Concept Mapping
7+
8+
| Concept | Quarkus 3 | dotnet10 |
9+
|---|---|---|
10+
| **HTTP framework** | RESTEasy (JAX-RS `@Path`, `@GET`) | ASP.NET Core Minimal API (`app.MapGet`) |
11+
| **DI container** | CDI (`@ApplicationScoped`, `@Inject`) | `IServiceCollection` (`AddScoped`, constructor injection) |
12+
| **ORM** | Hibernate ORM Panache (`PanacheEntityBase`) | EF Core (`DbContext`, `DbSet<T>`) |
13+
| **DB driver** | `quarkus-jdbc-postgresql` | Npgsql (`Npgsql.EntityFrameworkCore.PostgreSQL`) |
14+
| **Connection pool** | Agroal (built into Quarkus datasource) | EF Core connection pool (Npgsql default) |
15+
| **Health checks** | SmallRye Health (`/health/live`, `/health/ready`) | `AddHealthChecks().AddDbContextCheck<T>()` |
16+
| **Metrics** | Micrometer → OTel OTLP | OTel `WithMetrics` → OTLP (ASP.NET Core + runtime meters) |
17+
| **Traces** | SmallRye OTel, `traceidratio 0.1` | OTel `TraceIdRatioBasedSampler(0.1)` + `AddAspNetCoreInstrumentation()` |
18+
| **Logs** | OTel OTLP (`otel.logs.enabled: true`) | *(not yet wired — `UseOtlpExporter()` API gap in 1.11)* |
19+
| **OTel exporter** | OTLP gRPC `localhost:4317` | OTLP gRPC `localhost:4317` (same default) |
20+
| **JSON serialization** | Jackson (via `quarkus-rest-jackson`) | `System.Text.Json` with source-generated `FruitJsonContext` |
21+
| **Serialization optimization** | Reflection-free serializers (`enable-reflection-free-serializers: true`) | Source-generated context (`TypeInfoResolverChain`) — AOT-safe by design |
22+
| **Config format** | `application.yml` | `appsettings.json` / env vars / `builder.Configuration` |
23+
| **HTTP port** | 8080 (Quarkus default) | 8080 (explicit `UseUrls("http://0.0.0.0:8080")`) |
24+
| **Build output** | `quarkus-run.jar` (fast-jar) | Self-contained binary via `dotnet publish -c Release` |
25+
| **Native image** | GraalVM (`-Pnative`) — production-ready | `PublishAot` — blocked (see Native AOT section) |
26+
| **Test framework** | JUnit 5 + REST Assured + Quarkus DevServices | xUnit + `WebApplicationFactory` + Testcontainers |
27+
| **DB for tests** | Quarkus DevServices (auto-spins PostgreSQL) | Testcontainers (E2E) / mocked repos (unit) |
28+
| **Startup log** | `quarkus3 started in Xs` (built-in) | Custom `ApplicationStarted` callback emitting same pattern |
29+
| **CPU pinning** | `taskset --cpu-list 0-3` via `APP_CMD_PREFIX` | Same `APP_CMD_PREFIX` prepended to `runCmd` in `main.yml` |
30+
| **Memory limit** | `-Xmx512m` via `--jvm-memory``config.jvm.memory` | `DOTNET_GCHeapHardLimit` computed from `--jvm-memory` by `xmx_to_dotnet_gc_limit()` in `run-benchmarks.sh``config.dotnet.gcHeapHardLimit` |
31+
| **Processor count** | JVM infers from `taskset` cores | `DOTNET_ProcessorCount` set to `config.resources.app_cpus` (count derived from `--cpus-app`) |
32+
| **GC mode** | Server GC (JVM default for server workloads) | `DOTNET_gcServer=1` |
33+
34+
## dotnet10 — Status
35+
36+
### Done ✓
37+
38+
- [x] ASP.NET Core 10 implementation equivalent to the plain Quarkus version
39+
- [x] `scripts/stress.sh` detects dotnet binaries and sets correct runtime env vars
40+
- [x] `scripts/1strequest.sh` detects dotnet binaries and sets correct runtime env vars
41+
- [x] `scripts/perf-lab/main.yml``dotnet10` entry in `RUNTIMES` and `RUNTIMECMDS`; `updateScript: skip-version-update` prevents abort when qDup's `update-runtime-version` runs
42+
- [x] `scripts/perf-lab/run-benchmarks.sh``dotnet10` in `ALLOWED_RUNTIMES`
43+
- [x] `scripts/perf-lab/helpers/dotnet.yml``ensure-dotnet` requirement check
44+
- [x] `scripts/perf-lab/helpers/requirements.yml``ensure-dotnet` wired into `ensure-requirements`
45+
- [x] `dotnet10/README.md` documents how to run the dotnet app locally
46+
- [x] `Program.cs` emits a startup log matching the perf-lab `logFileStartedRegex`
47+
- [x] `scripts/remote-setup.sh` — one-command remote host preparation (SSH key install, passwordless sudo, environment verification)
48+
- [x] `README.md` — "Better: Run on a dedicated remote Linux box" section with 3-step workflow and `--repo-url` guidance for local changes
49+
- [x] perf-lab runtime constraints made fair — `main.yml` `runCmd` adds CPU pinning (`taskset` via `APP_CMD_PREFIX`), `DOTNET_gcServer=1`, and `DOTNET_ProcessorCount` from `config.resources.app_cpus`; `DOTNET_GCHeapHardLimit` is computed from `--jvm-memory` via `xmx_to_dotnet_gc_limit()` in `run-benchmarks.sh` so memory limits always match the JVM constraint
50+
51+
### Remaining / Open
52+
53+
- [ ] **E2E tests need a running database in CI.**
54+
The unit tests (`Dotnet10.Tests.Service.*`) run fine without infrastructure.
55+
The E2E tests (`Dotnet10.Tests.E2e.*`) require PostgreSQL with seed data.
56+
Current CI job excludes E2E tests with `--filter "FullyQualifiedName!~Dotnet10.Tests.E2e"`.
57+
Options:
58+
- Add Testcontainers (`Testcontainers.PostgreSql`) to `Dotnet10.Tests` so E2E tests
59+
spin up their own database, matching how Quarkus DevServices works for the JVM jobs.
60+
- Or start `ghcr.io/quarkusio/postgres-17-perf:main` as a GitHub Actions service
61+
container and remove the filter.
62+
63+
- [ ] **Dependabot NuGet monitoring.**
64+
`.github/dependabot.yml` only covers Maven (`package-ecosystem: maven`).
65+
Add a second entry for `nuget` pointing at `dotnet10/`.
66+
67+
- [ ] **perf-lab: `java -version` calls are misleading for dotnet runtimes.**
68+
`measure-build-times`, `measure-time-to-first-request`, and `measure-rss`
69+
all call `sh: java -version` unconditionally. For dotnet this is harmless noise (Java is
70+
always present because qDup itself is a Java tool), but it clutters the output.
71+
Consider making the call conditional on `${{RUNTIME.type}} != dotnet`.
72+
(`test-build` was removed from the allowed tests in the upstream refactor, so it is no
73+
longer a concern.)
74+
75+
- [ ] **perf-lab: no `--dotnet-version` selection support.** ⚠️ Hard
76+
`run-benchmarks.sh` lets you pin `--quarkus-version` and `--springboot3/4-version` because
77+
those are Maven dependency versions overridden at build time with a single property. The .NET
78+
SDK version is fundamentally different: it is an OS-level installation, not a project
79+
dependency. Whatever `dotnet` binary is on the `PATH` is what gets used, and `ensure-dotnet`
80+
in `helpers/dotnet.yml` merely logs it without any selection logic.
81+
To add proper version selection the following would all be needed:
82+
- Teach `helpers/dotnet.yml` to download and invoke Microsoft's `dotnet-install.sh` for a
83+
specific SDK version (mirroring how `ensure-java` / `ensure-graalvm` delegate to SDKMAN).
84+
- Add a `--dotnet-version` flag to `run-benchmarks.sh` and wire it through to a new qDup
85+
state variable (e.g. `config.dotnet.version`).
86+
- Handle SDK activation on the remote host — unlike SDKMAN's `sdk use`, the .NET SDK uses
87+
`global.json` or environment variables (`DOTNET_ROOT`, `PATH` prepending) to select a
88+
version, which must persist across the separate SSH commands qDup issues.
89+
- Decide whether to install into a shared location or a per-run sandbox to avoid races if
90+
benchmarks are ever run in parallel.
91+
92+
- [ ] **perf-lab: no dotnet profiling support.**
93+
The JVM runtimes can use async-profiler (JFR / flamegraph). dotnet has equivalent tools:
94+
`dotnet-trace` (CPU samples → speedscope / chromium), `dotnet-counters`, and Linux `perf`.
95+
A `scripts/perf-lab/helpers/dotnet-trace.yml` helper could mirror `async-profiler.yml`.
96+
97+
- [ ] **dotnet-native: not feasible yet.** ⛔ Blocked by Microsoft
98+
A `dotnet10-native` runtime (analogous to `quarkus3-native` / `spring3-native`) cannot be
99+
added in a like-for-like way with the current application stack. The blockers are fundamental,
100+
not configuration issues:
101+
102+
**Why it doesn't work today:**
103+
- **EF Core**: Query compilation relies on expression trees and reflection that are built and
104+
evaluated at runtime. Native AOT trims this code away. As of .NET 10, EF Core's Native AOT
105+
support is still marked experimental and requires switching to interceptors (compile-time
106+
query pre-compilation via `dotnet ef dbcontext optimize`), which is a significant
107+
architectural change not present in the Quarkus or Spring implementations.
108+
- **Npgsql**: Requires explicit compile-time registration of all mapped CLR types
109+
(`NpgsqlDataSourceBuilder.EnableDynamicJson()` is not AOT-safe). Entity mappings, enum
110+
converters, and range types must all be declared upfront.
111+
- **Serialization edge cases**: Even with the source-generated `FruitJsonContext` already in
112+
place, generic collections and nested DTOs can still fail at runtime because the AOT
113+
trimmer's static analysis misses types that are only referenced through EF Core's
114+
internal materializer.
115+
- **No equivalent of Quarkus build-time extensions**: Quarkus has spent years solving these
116+
exact problems for Hibernate and Jackson via build-time bytecode generation and
117+
`@RegisterForReflection`. The .NET ecosystem has no equivalent tooling depth yet.
118+
119+
**What would need to be true before adding `dotnet10-native`:**
120+
- EF Core Native AOT support reaches stable/non-experimental status (tracked in
121+
[dotnet/efcore#29840](https://github.com/dotnet/efcore/issues/29840))
122+
- `dotnet ef dbcontext optimize` (compiled query interceptors) works correctly with Npgsql
123+
without requiring application code changes beyond what Quarkus needs
124+
- The published binary produces identical HTTP behaviour to the JIT version under load
125+
(no trimming-related `NullReferenceException` or `MissingMethodException` at runtime)
126+
- Verified by running the full perf-lab test suite without errors across all 3 iterations
127+
128+
**Checklist for when the above is met:**
129+
- [ ] Run `dotnet ef dbcontext optimize` and commit the generated interceptors
130+
- [ ] Add `<PublishAot>true</PublishAot>` and `<TrimmerRootDescriptor>` to `Dotnet10.csproj`
131+
- [ ] Register all Npgsql type mappings explicitly in `Program.cs`
132+
- [ ] Add `dotnet10-native` RUNTIMECMD to `main.yml` (same pattern as `quarkus3-native` /
133+
`spring3-native`, with `buildCmd: "dotnet publish Dotnet10 -c Release -r linux-x64 -o publish"`)
134+
- [ ] Add `dotnet10-native` to `ALLOWED_RUNTIMES` and `DEFAULT_RUNTIMES` in `run-benchmarks.sh`
135+
- [ ] Add a `dotnet10-native-build-test` CI job
136+
- [ ] Run perf-lab end-to-end and confirm zero errors across all iterations before merging
137+
138+
## Adding a Future dotnet Version (e.g., dotnet11)
139+
140+
Every new major dotnet version needs changes in exactly these places.
141+
Work through the checklist top-to-bottom.
142+
143+
### 1. New application directory
144+
145+
```
146+
cp -r dotnet10 dotnet11
147+
```
148+
149+
Inside `dotnet11/`:
150+
- [ ] Rename solution and project files:
151+
`Dotnet10.sln``Dotnet11.sln`, `Dotnet10/``Dotnet11/`, `Dotnet10.Tests/``Dotnet11.Tests/`
152+
- [ ] Update `<TargetFramework>net10.0</TargetFramework>``net11.0` in both `.csproj` files
153+
- [ ] Update all NuGet package versions to their dotnet 11–compatible releases
154+
- [ ] Replace every occurrence of `dotnet10` / `Dotnet10` in source files and namespaces
155+
with `dotnet11` / `Dotnet11` (including the startup log message in `Program.cs`)
156+
- [ ] Update `dotnet11/README.md`
157+
158+
### 2. `scripts/perf-lab/main.yml`
159+
160+
- [ ] Add `dotnet11` to the `RUNTIMES` list (line ~62)
161+
- [ ] Add a `RUNTIMECMDS` entry (copy from `dotnet10`, update name, dir, and regex):
162+
```yaml
163+
- name: dotnet11
164+
type: dotnet
165+
dir: ${{DOTNET11_DIR}}
166+
updateScript: skip-version-update
167+
buildCmd: "dotnet publish Dotnet11 -c Release -o publish"
168+
runCmd: "./publish/dotnet11"
169+
logFileStartedRegex: ".*dotnet11.+started in.*"
170+
```
171+
- [ ] Add `update-state` entry (note: use `${{PROJ_REPO_DIR}}`, not `${{REPO_DIR}}/${{PROJ_REPO_NAME}}`):
172+
```yaml
173+
- set-state: RUN.DOTNET11_DIR ${{PROJ_REPO_DIR}}/dotnet11
174+
```
175+
- [ ] Add `output-vars` log line for `DOTNET11_DIR`
176+
177+
### 3. `scripts/perf-lab/run-benchmarks.sh`
178+
179+
- [ ] Add `dotnet11` to `ALLOWED_RUNTIMES` array (line ~338) and `DEFAULT_RUNTIMES` array (line ~339)
180+
- [ ] Add `dotnet11` to the `--runtimes` help text (line ~79)
181+
182+
### 4. `.github/workflows/main.yml`
183+
184+
- [ ] Add a `dotnet11-build-test` job (copy `dotnet-build-test`, change SDK version to
185+
`11.x` and working-directory to `dotnet11`)
186+
187+
### 5. `README.md`
188+
189+
- [ ] Add `dotnet11` entry to "What's in the repo"
190+
- [ ] Add `stress.sh` / `1strequest.sh` examples for dotnet11
191+
192+
### 6. `scripts/perf-lab/helpers/dotnet.yml`
193+
194+
No change needed — `ensure-dotnet` already checks the generic `dotnet --version`,
195+
which satisfies any installed SDK version.
196+
197+
If the benchmark environment must pin to a specific SDK version (e.g. to prevent a newer
198+
SDK from silently changing compiler behaviour), add a version-pinned variant:
199+
```yaml
200+
ensure-dotnet11:
201+
- log: Checking for .NET 11 SDK
202+
- sh: dotnet --version | grep -E '^11\.'
203+
```
204+
and wire it into `requirements.yml` instead of (or alongside) `ensure-dotnet`.
205+
206+
### 7. Dependabot
207+
208+
- [ ] Add a third `updates` entry in `.github/dependabot.yml` for the new directory.

0 commit comments

Comments
 (0)