rinha2-back-end-dotnet

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.

budget1.5 CPU / 550MB
runtime.NET 9 Native AOT
entrypointNGINX :9999
releaseGHCR + Pages

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.Json source generation with snake_case output and no reflection-heavy runtime path.
  • Database access: Npgsql 10.0.2 with a bounded pool and Multiplexing=true in 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:16 in 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

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 1 through 5 exist.
  • 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.cs rejects invalid client IDs, non-positive values, invalid transaction types, and missing/empty/long descriptions before calling the database.
  • InsertTransacao in docker-entrypoint-initdb.d/rinha.dump.sql performs 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:

  • CreateSlimBuilder trims default ASP.NET Core hosting overhead.
  • Route handlers map directly to the required contest endpoints.
  • JSON output uses JsonNamingPolicy.SnakeCaseLower and generated metadata from SourceGenerationContext.
  • 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=true defines EXTRAOPTIMIZE.

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/WebApi outside Docker. Docker builds use the repository Dockerfile’s .NET 10 SDK/runtime images while the project itself targets net9.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:

  1. Restores and builds the WebApi project with AOT=true, TRIM=false, and EXTRA_OPTIMIZE=true.
  2. Builds and pushes the amd64 image to GHCR.
  3. Starts the production compose stack and checks /healthz.
  4. Runs the load-test job and uploads the HTML stress-test report artifact.
  5. Builds and pushes the arm64 image.
  6. Merges the platform images into the latest manifest.

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 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:

  1. Checkout the repository.
  2. Install the .NET 9 SDK.
  3. Restore src/WebApi/WebApi.csproj with release flags.
  4. Build the WebApi project with AOT=true and EXTRA_OPTIMIZE=true.
  5. Start the compose stack through NGINX.
  6. Check http://localhost:9999/healthz.
  7. 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.