Documentation control surface
From contest rules to deploy evidence, without leaving the docs.
Use this page as the operator map, then open focused pages when you need implementation detail, commands, or release proof.
Rules, endpoints, validation behavior, and resource envelope.
Runtime ArchitectureNGINX, two Native AOT API containers, PostgreSQL stored procedures, and telemetry.
Runbook Getting StartedClone, boot the compose stack, smoke `/healthz`, and issue sample requests.
Contract API ReferenceEndpoint payloads, response shapes, validation rules, and current implementation notes.
Evidence PerformanceBudget split, latency choices, k6 lane, and release validation links.
Release CI/CD PipelinePR gates, GHCR publishing, CodeQL, and Pages deploy workflow.
rinha2-back-end-dotnet
Operator documentation for a C#/.NET 9 Native AOT implementation of Rinha de Backend 2024/Q1. The system exposes the required fictional bank API behind NGINX, keeps transaction rules inside PostgreSQL stored procedures, and stays inside the 1.5 CPU / 550MB container budget.
Start here
| Need | Read | Outcome |
|---|---|---|
| Understand the contest rules | Challenge | Endpoints, validation rules, and scoring constraints |
| See the runtime topology | Architecture | Service map, CPU/RAM split, database strategy |
| Run the stack locally | Getting Started | Compose commands, smoke checks, sample requests |
| Check endpoint contracts | API Reference | Payloads, responses, status codes, and implementation notes |
| Review benchmark evidence | Performance | Resource margins, test shape, and report links |
| Follow releases and deploys | CI/CD Pipeline | PR gates, image publishing, Pages deploy flow |
Implementation profile
- Runtime: ASP.NET Core Minimal API targeting
net9.0, compiled with Native AOT for the release image. - Serialization:
System.Text.Jsonsource generation withsnake_caseoutput and no reflection-heavy runtime path. - Database access: Npgsql 10.0.2 with a bounded pool and
Multiplexing=truein the compose connection string. - Consistency boundary: PostgreSQL stored procedures validate limits and apply transactions atomically.
- Topology: Two API containers behind NGINX
least_conn, backed by one PostgreSQL container (postgres:16in the development compose file). - Observability: OpenTelemetry, Grafana LGTM, InfluxDB, and k6 dashboards are available in the development stack.
Proof points
| Claim | Backing source |
|---|---|
| Total challenge budget is 1.5 CPU / 550MB RAM | docker-compose.yml service resource limits |
| API containers run at 0.4 CPU / 100MB each | webapi1-dotnet and webapi2-dotnet compose definitions |
| Database owns the atomic business rules | docker-entrypoint-initdb.d/rinha.dump.sql stored procedures |
| Release images and the multi-arch manifest are pushed to GHCR | .github/workflows/main-release.yml |
| Docs publish to GitHub Pages after main merges | .github/workflows/deploy.yml |
Quick command
git clone https://github.com/jonathanperis/rinha2-back-end-dotnet.git
cd rinha2-back-end-dotnet
docker compose up nginx -d --build
curl http://localhost:9999/healthz
Links
Challenge
Rinha de Backend 2024/Q1
Rinha de Backend is a Brazilian backend challenge focused on constrained, concurrent API design. The 2024/Q1 edition models a small fictional bank called Rinha Financeira. The workload stresses debit and credit transactions plus balance statements for five predefined clients.
Required endpoints
| Endpoint | Method | Purpose | Expected statuses |
|---|---|---|---|
/clientes/{id}/transacoes |
POST | Submit a debit (d) or credit (c) transaction for client IDs 1 through 5 |
200, 404, 422 for invalid payloads |
/clientes/{id}/extrato |
GET | Return balance, credit limit, statement time, and recent transactions | 200, 404 |
/healthz |
GET | Local and CI health check for this implementation | 200 |
Validation rules
- Only client IDs
1through5exist. - Transaction type must be debit (
d) or credit (c). - Transaction value must be a positive integer amount in cents (
valor > 0). - Description must be present, non-empty, and at most 10 characters.
- Transaction type must be checked by the API before the request reaches PostgreSQL.
- Debits are intended not to push the account beyond the configured credit limit.
- Statement responses return the current balance plus the latest 10 transactions.
Resource envelope
The official workload is intentionally small but hostile to inefficient coordination. All counted runtime containers must fit inside:
| Budget | Limit | This repository’s counted split |
|---|---|---|
| CPU | 1.5 total | API 0.8, PostgreSQL 0.5, NGINX 0.2 |
| RAM | 550MB total | API 200MB, PostgreSQL 330MB, NGINX 20MB |
The k6 runner and observability containers are test infrastructure. They are useful for local inspection, but they are not part of the counted API budget.
What makes the challenge hard
- Concurrent requests can target the same client at the same time.
- Balance updates and statement reads must stay consistent.
- The database budget is tight enough that careless connection pools or chatty queries can dominate latency.
- The API budget leaves little room for runtime warmup, reflection, or over-instrumented production paths.
Source
Full specification: github.com/zanfranceschi/rinha-de-backend-2024-q1
Current implementation compatibility note
Payload validation is split across the API and PostgreSQL:
Program.csrejects invalid client IDs, non-positive values, invalid transaction types, and missing/empty/long descriptions before calling the database.InsertTransacaoindocker-entrypoint-initdb.d/rinha.dump.sqlperforms the atomic balance update and transaction insert.
One important edge case is worth calling out: when a debit would exceed the account limit, the current SQL function returns the existing balance without inserting a transaction. Because the route handler treats the returned balance as a successful result, this case can surface as 200 OK with an unchanged balance rather than the strict Rinha 422 behavior. Treat this as a known implementation note until the SQL/API contract is changed.
Architecture
Runtime topology
The production path follows the Rinha pattern: two API instances behind NGINX, one PostgreSQL database, and a separate load-test/observability lane for local and CI verification.
client / k6
-> nginx:9999 (least_conn)
-> webapi1-dotnet:8080
-> webapi2-dotnet:8080
-> PostgreSQL (stored procedures)
Counted services
| Service | Role | CPU | RAM | Notes |
|---|---|---|---|---|
webapi1-dotnet |
.NET 9 Native AOT API instance | 0.4 | 100MB | ASP.NET Core Minimal API |
webapi2-dotnet |
.NET 9 Native AOT API instance | 0.4 | 100MB | Same image and config as instance 1 |
nginx |
Reverse proxy and load balancer | 0.2 | 20MB | Uses least_conn balancing |
db |
PostgreSQL database (postgres:16 in dev compose) |
0.5 | 330MB | Stores schema, limits, balances, transactions |
| Total | Counted challenge budget | 1.5 | 550MB | Matches the contest envelope |
Support services
| Service | Purpose | Counted in challenge budget? |
|---|---|---|
k6 |
Drives stress tests through NGINX | No |
influxdb |
Stores k6 time-series output in dev mode | No |
otel-lgtm |
Local Grafana, Loki, Tempo, and metrics stack | No |
API layer
The API project targets net9.0; its Dockerfile currently builds and runs it with Microsoft .NET 10.0 SDK/runtime images. The API is intentionally thin:
CreateSlimBuildertrims default ASP.NET Core hosting overhead.- Route handlers map directly to the required contest endpoints.
- JSON output uses
JsonNamingPolicy.SnakeCaseLowerand generated metadata fromSourceGenerationContext. - Client IDs and credit limits are kept in a small in-memory map for fast invalid-ID rejection.
- OpenTelemetry instrumentation is compiled out when
ExtraOptimize=truedefinesEXTRAOPTIMIZE.
Database boundary
PostgreSQL owns the race-sensitive work. The API calls stored procedures for balance reads and transaction inserts instead of doing multi-step application-side coordination. Payload-shape validation remains API-side; the stored procedure should be read as the atomic consistency boundary, not as the only validation layer.
| Procedure | Responsibility |
|---|---|
InsertTransacao |
Check client existence, apply the atomic balance update, enforce the database-side credit-limit guard, and insert the transaction row when the update succeeds |
GetSaldoClienteById |
Return balance metadata and recent transactions as JSONB |
The compose command also tunes write durability for the contest setting:
checkpoint_timeout=600
max_wal_size=4096
synchronous_commit=0
fsync=0
full_page_writes=0
Connection strategy
The API uses one Npgsql data source per process and a bounded connection string:
Minimum Pool Size=10;Maximum Pool Size=10;Multiplexing=true;
That shape keeps database concurrency explicit, avoids unbounded connection growth, and lets Npgsql pipeline compatible work where possible.
Release profile
| Build flag | Default in release workflow | Effect |
|---|---|---|
AOT |
true |
Publishes Native AOT binaries for the release image |
TRIM |
false |
Leaves trimming separate from AOT path |
EXTRA_OPTIMIZE |
true |
Removes observability/runtime support guarded by EXTRAOPTIMIZE |
BUILD_CONFIGURATION |
Release |
Uses optimized .NET build configuration |
API and database contract
| Rule | Enforced by | Source |
|---|---|---|
Client ID is one of 1..5 |
API fast path and database existence check | Program.cs, InsertTransacao |
valor is positive |
API | IsTransacaoValid |
tipo is exactly c or d |
API | IsTransacaoValid |
descricao is present and <= 10 chars |
API, with SQL parameter width as a backstop | IsTransacaoValid, InsertTransacao(... descricao VARCHAR(10)) |
| Balance update and transaction insert stay atomic | PostgreSQL | InsertTransacao |
| Statement returns newest 10 transactions | PostgreSQL | GetSaldoClienteById |
The NGINX config currently uses least_conn. If this repository is used as a strict official challenge submission, keep the config comments and docs aligned with whatever balancing policy is allowed by the target ruleset.
Getting Started
Prerequisites
- Docker with Docker Compose.
- Git.
- Optional: .NET 9 SDK if you want to build
src/WebApioutside Docker. Docker builds use the repository Dockerfile’s .NET 10 SDK/runtime images while the project itself targetsnet9.0.
Clone and run
git clone https://github.com/jonathanperis/rinha2-back-end-dotnet.git
cd rinha2-back-end-dotnet
docker compose up nginx -d --build
The API is exposed through NGINX at:
http://localhost:9999
Smoke check
curl -i http://localhost:9999/healthz
A healthy stack returns HTTP/1.1 200 OK.
API endpoints
| Endpoint | Method | Description |
|---|---|---|
/clientes/{id}/transacoes |
POST | Submit a debit or credit transaction |
/clientes/{id}/extrato |
GET | Get account balance and recent transactions |
/healthz |
GET | Health check used by CI and local smoke tests |
Example transaction
Credits use tipo: "c", debits use tipo: "d". Values are positive integer cents.
curl -X POST http://localhost:9999/clientes/1/transacoes \
-H "Content-Type: application/json" \
-d '{"valor": 1000, "tipo": "c", "descricao": "deposito"}'
The successful response returns the client ID, limit, and updated balance:
{
"id": 1,
"limite": 100000,
"saldo": 1000
}
Invalid payloads return 422. Unknown client IDs return 404.
Example statement
curl http://localhost:9999/clientes/1/extrato
Example response:
{
"saldo": {
"total": 1000,
"limite": 100000,
"data_extrato": "2026-04-01T19:20:20.000000"
},
"ultimas_transacoes": [
{
"valor": 1000,
"tipo": "c",
"descricao": "deposito"
}
]
}
For the full endpoint contract and the current over-limit debit behavior, see API Reference.
Run the load-test lane
The compose file includes the shared k6 runner and observability services. After the API stack is up, run:
docker compose up k6
By default, the k6 service runs with MODE=dev, exports data to InfluxDB, and exposes the k6 web dashboard on port 5665.
Useful local ports
| Port | Service | Notes |
|---|---|---|
9999 |
NGINX | Public API entrypoint |
5010 |
API instance 1 | Direct container port for debugging |
5011 |
API instance 2 | Direct container port for debugging |
5432 |
PostgreSQL | Local database access |
3000 |
Grafana LGTM | Observability UI |
5665 |
k6 web dashboard | Enabled by K6_WEB_DASHBOARD=true |
Stop and clean up
docker compose down
Add -v only when you intentionally want to remove database and telemetry volumes:
docker compose down -v
API Reference
Base URL
Local and CI smoke tests reach the API through NGINX:
http://localhost:9999
The production compose file exposes the same public entrypoint on port 9999.
Client IDs and limits
The implementation has five predefined clients. The API keeps this small map in memory for fast invalid-client rejection, and PostgreSQL seeds the same limits in docker-entrypoint-initdb.d/rinha.dump.sql.
| Client ID | Limit, in cents |
|---|---|
1 |
100000 |
2 |
80000 |
3 |
1000000 |
4 |
10000000 |
5 |
500000 |
POST /clientes/{id}/transacoes
Submits a credit or debit transaction.
Request body
{
"valor": 1000,
"tipo": "c",
"descricao": "deposito"
}
| Field | Type | Rule |
|---|---|---|
valor |
integer | Required positive integer amount in cents (> 0) |
tipo |
string | Required; "c" for credit or "d" for debit |
descricao |
string | Required, non-empty, maximum 10 characters |
Successful response
The response uses snake_case JSON output generated by System.Text.Json source generation.
{
"id": 1,
"limite": 100000,
"saldo": 1000
}
Status codes
| Status | When |
|---|---|
200 |
Transaction accepted and a balance is returned |
404 |
Client ID is not one of 1 through 5 |
422 |
Payload validation fails (valor <= 0, invalid tipo, missing/empty/long descricao) |
Current implementation note
The intended Rinha contract treats a debit that would exceed the client’s limit as an unprocessable transaction. The current PostgreSQL function keeps the balance unchanged and returns the current balance when the limit update fails, so the API can return 200 with an unchanged balance for this case. If strict challenge behavior is required, adjust InsertTransacao/the route handler before documenting over-limit debits as guaranteed 422 responses.
GET /clientes/{id}/extrato
Returns the current account statement.
Successful response
{
"saldo": {
"total": 1000,
"limite": 100000,
"data_extrato": "2026-04-01T19:20:20.000000"
},
"ultimas_transacoes": [
{
"valor": 1000,
"tipo": "c",
"descricao": "deposito"
}
]
}
The statement returns the latest 10 transactions ordered from newest to oldest.
Status codes
| Status | When |
|---|---|
200 |
Client exists and statement data was returned |
404 |
Client ID is not one of 1 through 5 |
GET /healthz
Health check used by local smoke tests and GitHub Actions.
| Status | When |
|---|---|
200 |
ASP.NET Core health checks pass |
Responsibility split
| Layer | Responsibility |
|---|---|
API (Program.cs) |
Route mapping, JSON serialization, payload validation, fast invalid-client checks, database calls |
PostgreSQL (rinha.dump.sql) |
Seed clients, keep balances, apply atomic balance updates, insert transaction rows, return recent statement data |
NGINX (nginx.conf) |
Public port 9999 and load balancing across the two API instances |
Performance
Budget summary
The challenge allows 1.5 CPU and 550MB RAM across the counted runtime containers. This implementation uses the full CPU envelope deliberately, while keeping each component’s responsibility narrow.
| Area | Limit | Repository split | Why it matters |
|---|---|---|---|
| API CPU | 0.8 total | Two instances at 0.4 each | Parallel request handling behind NGINX |
| Database CPU | 0.5 | One PostgreSQL container | Atomic transaction and statement logic |
| NGINX CPU | 0.2 | One reverse proxy | Cheap load balancing on port 9999 |
| RAM | 550MB total | 200MB API, 330MB DB, 20MB NGINX | Fits the official container budget |
Implementation choices that affect latency
| Choice | Performance intent |
|---|---|
| Native AOT | Avoid JIT warmup and reduce runtime footprint |
CreateSlimBuilder |
Keep ASP.NET Core hosting minimal |
| JSON source generation | Avoid reflection-heavy serialization paths |
| Stored procedures | Keep validation and writes atomic in PostgreSQL |
| Bounded Npgsql pool | Avoid unbounded database connections under load |
| Npgsql multiplexing | Improve throughput for compatible database work |
least_conn NGINX balancing |
Send new work to the less busy API instance |
Stress testing
Load tests run through the shared rinha2-back-end-k6 test suite. The runner drives transaction and statement requests through NGINX, so the measured path includes load balancing, both API containers, and PostgreSQL.
The homepage benchmark cards are intentionally treated as archived-run claims, not timeless guarantees. When updating numbers like 46k+ requests/second, <50ms p95 latency, or 99.9% success rate, tie the change to a concrete archived report and keep the report file in docs/public/reports/.
| Claim on homepage | What to verify before changing it |
|---|---|
| Requests/second | The k6 report’s request-throughput metric for the selected run |
| p95 latency | The report’s p95 request-duration metric for the same run |
| Success rate | Failed request/check rate for the same run |
| PASS report link | The corresponding stress-test-report-YYYYMMDDHHMMSS.html file is present in the Pages report archive |
CI runners and local machines can vary. Prefer wording such as “archived run” or “representative run” unless the number comes from a repeatable benchmark protocol documented here.
docker compose up nginx -d --build
docker compose up k6
Local observability path
The development compose file keeps telemetry separate from the counted service budget:
| Tool | Role |
|---|---|
| k6 web dashboard | Live load-test progress on port 5665 |
| InfluxDB | Time-series sink for k6 dev-mode output |
| Grafana LGTM | Local dashboards and OpenTelemetry endpoint |
| OpenTelemetry | API traces, metrics, and logs when not compiled out |
Release validation
The Main Release workflow validates more than a build:
- Restores and builds the WebApi project with
AOT=true,TRIM=false, andEXTRA_OPTIMIZE=true. - Builds and pushes the amd64 image to GHCR.
- Starts the production compose stack and checks
/healthz. - Runs the load-test job and uploads the HTML stress-test report artifact.
- Builds and pushes the arm64 image.
- Merges the platform images into the
latestmanifest.
Report archive workflow
The release workflow uploads the HTML stress-test report as a GitHub Actions artifact. Reports that should be published on Pages are committed under docs/public/reports/; the Astro reports page indexes every .html file in that directory at build time. The reports index is an archive, not a parser, so summary metrics must be copied into docs/homepage copy deliberately.
Evidence links
| Evidence | Link |
|---|---|
| Main release workflow | .github/workflows/main-release.yml |
| Build check workflow | .github/workflows/build-check.yml |
| Runtime compose budget | docker-compose.yml |
| Published image | ghcr.io/jonathanperis/rinha2-back-end-dotnet:latest |
CI/CD Pipeline
Workflow map
This repository uses GitHub Actions for PR validation, release image publishing, security analysis, and the public documentation deploy.
| Workflow | Trigger | What it proves |
|---|---|---|
build-check.yml |
Pull requests to main, manual dispatch |
WebApi restores/builds and the compose stack answers /healthz |
main-release.yml |
Push to main, manual dispatch |
GHCR images build, the production compose stack starts, load tests run, multi-arch manifest is created |
codeql.yml |
Pull requests, pushes, weekly schedule | C# security analysis completes successfully |
deploy.yml |
Push to main, manual dispatch |
Astro docs build and deploy to GitHub Pages |
PR gate
Pull requests run the fast safety checks before merge:
- Checkout the repository.
- Install the .NET 9 SDK.
- Restore
src/WebApi/WebApi.csprojwith release flags. - Build the WebApi project with
AOT=trueandEXTRA_OPTIMIZE=true. - Start the compose stack through NGINX.
- Check
http://localhost:9999/healthz. - Run CodeQL analysis.
Main release path
After a PR is rebased into main, the release workflow publishes the runtime image:
| Job | Purpose |
|---|---|
setup-build-test |
Restore and build the net9.0 WebApi project with release/AOT flags |
build-push-amd64 |
Build and push linux/amd64 image to GHCR |
container-test |
Start the production compose stack and check /healthz |
load-test |
Run the k6 validation lane and upload the HTML stress-test report artifact |
build-push-arm64 |
Build and push linux/arm64/v8 image |
merge-manifest |
Publish the multi-arch latest manifest |
Published artifacts
| Artifact | Location |
|---|---|
| Container image | ghcr.io/jonathanperis/rinha2-back-end-dotnet:latest |
| ARM64 image tag | ghcr.io/jonathanperis/rinha2-back-end-dotnet:latest-arm64 |
| Docs site | https://jonathanperis.github.io/rinha2-back-end-dotnet/ |
| Docs section | https://jonathanperis.github.io/rinha2-back-end-dotnet/docs/ |
Build/version matrix
| Path | SDK/runtime | Flags | Purpose |
|---|---|---|---|
| Local Docker/dev compose | .NET 10.0 SDK/runtime images from src/WebApi/Dockerfile |
AOT=true, TRIM=false, EXTRA_OPTIMIZE=false |
Local stack with OpenTelemetry support |
| PR Build Check | actions/setup-dotnet 9.0.x |
AOT=true, TRIM=false, EXTRA_OPTIMIZE=true |
Fast compile gate plus compose health check |
| Main Release image | .NET 10.0 Docker build/runtime images |
AOT=true, TRIM=false, EXTRA_OPTIMIZE=true |
GHCR release image and multi-arch manifest |
| CodeQL | actions/setup-dotnet 9.0.x |
Plain Release build | Security analysis |
| Docs deploy | Bun + Astro ^6.4.2 |
NODE_ENV=production base path |
GitHub Pages static site |
The project targets net9.0 even when Docker build images are 10.0.
Documentation deploy
The Pages workflow delegates to Jonathan’s shared GitHub Pages workflow and uses Bun as the package manager. The docs package is an Astro 6.4 static site under docs/; Markdown content lives in docs/wiki/, and production routes are served under the /rinha2-back-end-dotnet base path.
The docs site uses Astro’s markdown.processor API with @astrojs/markdown-satteri. That keeps Markdown rendering explicit, but it also means future Remark/Rehype-style extensions should be tested against Sätteri before they are assumed to work.
Operational notes
- Use PR branches for this repository. The repo allows rebase merges and keeps main linear.
- Main releases are intentionally heavier than PR checks because they publish images and execute the load-test lane.
- Docs changes still trigger the main release workflow after merge, so report Pages status separately from image publishing when a change only affects documentation.