From 1bd0aaf55a83a631c8e1c69574ae8dd941e662ab Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Fri, 1 May 2026 11:22:53 +0000 Subject: [PATCH] Add rendering fortunes test --- README.md | 3 +- data/pgdb-seed.sql | 49 + frameworks/aspnet-minimal/Models.cs | 2 + .../aspnet-minimal/Pages/Fortunes.cshtml | 16 + .../aspnet-minimal/Pages/Fortunes.cshtml.cs | 31 + frameworks/aspnet-minimal/Program.cs | 7 + frameworks/aspnet-minimal/meta.json | 1 + requests/fortunes-get.raw | 3 + scripts/benchmark.sh | 2 +- scripts/lib/framework.sh | 2 +- scripts/lib/profiles.sh | 2 + scripts/lib/tools/gcannon.sh | 7 + scripts/validate.sh | 71 +- site/content/_index.md | 7 +- site/content/docs/scoring/composite-score.md | 1 + .../docs/test-profiles/h1/isolated/_index.md | 1 + .../h1/isolated/fortunes/_index.md | 10 + .../h1/isolated/fortunes/implementation.md | 104 + .../h1/isolated/fortunes/validation.md | 55 + site/data/api-16-1024.json | 24 +- site/data/api-4-256.json | 24 +- site/data/async-db-1024.json | 18 +- site/data/baseline-4096.json | 16 +- site/data/baseline-512.json | 16 +- site/data/baseline-h2-1024.json | 16 +- site/data/baseline-h2-256.json | 14 +- site/data/crud-4096.json | 20 + site/data/fortunes-1024.json | 21 + site/data/json-4096.json | 18 +- site/data/json-comp-16384.json | 34 +- site/data/json-comp-4096.json | 36 +- site/data/json-comp-512.json | 34 +- site/data/json-tls-4096.json | 14 +- site/data/limited-conn-4096.json | 18 +- site/data/limited-conn-512.json | 18 +- site/data/pipelined-4096.json | 14 +- site/data/pipelined-512.json | 14 +- site/data/static-1024.json | 12 +- site/data/static-4096.json | 14 +- site/data/static-6800.json | 14 +- site/data/static-h2-1024.json | 12 +- site/data/static-h2-256.json | 14 +- site/data/unary-grpc-1024.json | 19 + site/data/unary-grpc-256.json | 19 + site/data/unary-grpc-tls-1024.json | 19 + site/data/unary-grpc-tls-256.json | 19 + site/data/upload-256.json | 18 +- site/data/upload-32.json | 16 +- .../shortcodes/leaderboard-composite.html | 1 + site/static/logs/api-16/1024/pyronova.log | 27 +- site/static/logs/api-4/256/pyronova.log | 25 +- site/static/logs/async-db/1024/pyronova.log | 25 +- .../static/logs/baseline-h2/1024/pyronova.log | 27 +- site/static/logs/baseline-h2/256/pyronova.log | 27 +- site/static/logs/baseline/4096/pyronova.log | 25 +- site/static/logs/baseline/512/pyronova.log | 27 +- site/static/logs/crud/4096/pyronova.log | 18 + .../logs/fortunes/1024/aspnet-minimal.log | 2 + site/static/logs/json-comp/16384/pyronova.log | 25 +- site/static/logs/json-comp/4096/pyronova.log | 25 +- site/static/logs/json-comp/512/pyronova.log | 25 +- site/static/logs/json-tls/4096/pyronova.log | 27 +- site/static/logs/json/4096/pyronova.log | 27 +- .../logs/limited-conn/4096/pyronova.log | 27 +- .../static/logs/limited-conn/512/pyronova.log | 27 +- site/static/logs/pipelined/4096/pyronova.log | 27 +- site/static/logs/pipelined/512/pyronova.log | 27 +- site/static/logs/static-h2/1024/pyronova.log | 27 +- site/static/logs/static-h2/256/pyronova.log | 27 +- site/static/logs/static/1024/pyronova.log | 27 +- site/static/logs/static/4096/pyronova.log | 27 +- site/static/logs/static/6800/pyronova.log | 27 +- .../logs/unary-grpc-tls/1024/pyronova.log | 18 + .../logs/unary-grpc-tls/256/pyronova.log | 18 + site/static/logs/unary-grpc/1024/pyronova.log | 18 + site/static/logs/unary-grpc/256/pyronova.log | 18 + site/static/logs/upload/256/pyronova.log | 2633 +++++++++++------ site/static/logs/upload/32/pyronova.log | 404 ++- 78 files changed, 2966 insertions(+), 1638 deletions(-) create mode 100644 frameworks/aspnet-minimal/Pages/Fortunes.cshtml create mode 100644 frameworks/aspnet-minimal/Pages/Fortunes.cshtml.cs create mode 100644 requests/fortunes-get.raw create mode 100644 site/content/docs/test-profiles/h1/isolated/fortunes/_index.md create mode 100644 site/content/docs/test-profiles/h1/isolated/fortunes/implementation.md create mode 100644 site/content/docs/test-profiles/h1/isolated/fortunes/validation.md create mode 100644 site/data/fortunes-1024.json create mode 100644 site/static/logs/crud/4096/pyronova.log create mode 100644 site/static/logs/fortunes/1024/aspnet-minimal.log create mode 100644 site/static/logs/unary-grpc-tls/1024/pyronova.log create mode 100644 site/static/logs/unary-grpc-tls/256/pyronova.log create mode 100644 site/static/logs/unary-grpc/1024/pyronova.log create mode 100644 site/static/logs/unary-grpc/256/pyronova.log diff --git a/README.md b/README.md index 34cc3a854..d26c90ff2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ HTTP framework benchmark platform. -26 test profiles. 64-core dedicated hardware. Same conditions for every framework. +27 test profiles. 64-core dedicated hardware. Same conditions for every framework. [View Leaderboard](https://www.http-arena.com/) | [Documentation](https://www.http-arena.com/docs/) | [Add a Framework](https://www.http-arena.com/docs/add-framework/) @@ -33,6 +33,7 @@ Always specify `-f `. Results are automatically compared against the | Connection | `baseline`, `pipelined`, `limited-conn` | Mixed GET/POST with query parsing (512/4K conns), 16× batched pipelining, short-lived connections that close after 10 requests | | Workload | `json`, `json-comp`, `json-tls`, `upload`, `static` | JSON serialization, gzip/brotli compression, HTTP/1.1 over TLS, 20 MB body ingestion, 20-file static asset serving | | Database | `async-db`, `crud` | Async Postgres sequential scan; realistic REST API with cached reads, list, upsert, update, and optional Redis cache | +| Templates | `fortunes` * | DB query + HTML template render (TechEmpower-style Fortunes). Reference-only — measures template-engine throughput, not part of the composite score | | Multi-endpoint | `api-4`, `api-16` | Mixed baseline + JSON + async-db at CPU-budget cliffs (4 and 16 CPUs) | | H/2 | `baseline-h2`, `static-h2`, `baseline-h2c`, `json-h2c` | Baseline + static over TLS with h2 stream multiplexing; baseline + JSON over cleartext h2 (prior-knowledge, port 8082) | | H/3 | `baseline-h3`, `static-h3` | Baseline and static over QUIC with TLS 1.3 | diff --git a/data/pgdb-seed.sql b/data/pgdb-seed.sql index 59209863c..f5159ac97 100644 --- a/data/pgdb-seed.sql +++ b/data/pgdb-seed.sql @@ -100043,3 +100043,52 @@ INSERT INTO users VALUES (17, 'Bob Martinez', 'bob@bench.local', 'free'), (8, 'Carla Nguyen', 'carla@bench.local', 'pro'), (99, 'Dmitri Volkov', 'dmitri@bench.local', 'enterprise'); + +-- ── fortunes table ───────────────────────────────────────────────────────── +-- Backs the /fortunes profile (template-engine benchmark). 12 fixed rows; +-- the handler appends a 13th row in-memory at request time and sorts by +-- message before rendering. Row 11 contains a raw '), + (12, 'フレームワークのベンチマーク'); + +-- Synthetic rows 13..200 — bulk content so per-request render time is +-- proportional to a real engine workload instead of being dwarfed by the +-- PG round-trip. With only the 12 TE-canonical rows the profile measures +-- the database driver more than the template engine; adding 188 rows +-- means render cost scales linearly while the query cost stays fixed +-- (still one round-trip, still single-page table read). Each row contains +-- &, ', " and an em-dash so escape work runs on every cell, not just on +-- row 11. Diverges from TE Fortunes' 12-row spec by design — see +-- docs/test-profiles/h1/isolated/fortunes/. +INSERT INTO fortune (id, message) +SELECT g, CASE g % 7 + WHEN 0 THEN 'Adage #' || g || ': "Premature optimization is the root of all evil." — D. Knuth' + WHEN 1 THEN 'Adage #' || g || ': There are 10 kinds of people: those who understand binary & those who don''t.' + WHEN 2 THEN 'Adage #' || g || ': Walking on water & developing from a spec are easy if both are frozen — E. Berard' + WHEN 3 THEN 'Adage #' || g || ': In theory there is no difference between theory & practice. In practice there is.' + WHEN 4 THEN 'Adage #' || g || ': "It''s not a bug, it''s an undocumented feature." — anonymous' + WHEN 5 THEN 'Adage #' || g || ': The cheapest, fastest & most reliable components are those that aren''t there.' + ELSE 'Adage #' || g || ': Programs must be written for people to read & only incidentally for machines to execute.' +END +FROM generate_series(13, 200) AS g; diff --git a/frameworks/aspnet-minimal/Models.cs b/frameworks/aspnet-minimal/Models.cs index 3f12c8ea4..93e04f364 100644 --- a/frameworks/aspnet-minimal/Models.cs +++ b/frameworks/aspnet-minimal/Models.cs @@ -60,3 +60,5 @@ sealed class CrudWriteResponse public int Price { get; set; } public int Quantity { get; set; } } + +public sealed record Fortune(int Id, string Message); diff --git a/frameworks/aspnet-minimal/Pages/Fortunes.cshtml b/frameworks/aspnet-minimal/Pages/Fortunes.cshtml new file mode 100644 index 000000000..880992619 --- /dev/null +++ b/frameworks/aspnet-minimal/Pages/Fortunes.cshtml @@ -0,0 +1,16 @@ +@page "/fortunes" +@model FortunesModel +@{ Layout = null; } + + +Fortunes + + + +@foreach (var f in Model.Fortunes) +{ + +} +
idmessage
@f.Id@f.Message
+ + diff --git a/frameworks/aspnet-minimal/Pages/Fortunes.cshtml.cs b/frameworks/aspnet-minimal/Pages/Fortunes.cshtml.cs new file mode 100644 index 000000000..16bd4fc67 --- /dev/null +++ b/frameworks/aspnet-minimal/Pages/Fortunes.cshtml.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +public sealed class FortunesModel : PageModel +{ + public List Fortunes { get; private set; } = []; + + public async Task OnGetAsync() + { + if (AppData.PgDataSource is null) + return new StatusCodeResult(500); + + var list = new List(201); + await using (var cmd = AppData.PgDataSource.CreateCommand("SELECT id, message FROM fortune")) + await using (var reader = await cmd.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + list.Add(new Fortune(reader.GetInt32(0), reader.GetString(1))); + } + } + + // Runtime-injected row defeats whole-page memoization: the rendered + // HTML must vary per request, even though the seeded rows don't. + list.Add(new Fortune(0, "Additional fortune added at request time.")); + list.Sort(static (a, b) => string.CompareOrdinal(a.Message, b.Message)); + + Fortunes = list; + return Page(); + } +} diff --git a/frameworks/aspnet-minimal/Program.cs b/frameworks/aspnet-minimal/Program.cs index 40a3c1189..be17f826a 100644 --- a/frameworks/aspnet-minimal/Program.cs +++ b/frameworks/aspnet-minimal/Program.cs @@ -7,6 +7,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Logging.ClearProviders(); builder.Services.AddMemoryCache(); +builder.Services.AddRazorPages(); var certPath = Environment.GetEnvironmentVariable("TLS_CERT") ?? "/certs/server.crt"; var keyPath = Environment.GetEnvironmentVariable("TLS_KEY") ?? "/certs/server.key"; @@ -84,6 +85,12 @@ app.MapPost("/crud/items", Handlers.CrudCreate); app.MapPut("/crud/items/{id:int}", Handlers.CrudUpdate); +// /fortunes is served by the Razor page at Pages/Fortunes.cshtml +// (route "/fortunes" declared via the page's @page directive). MapRazorPages +// wires up the MVC/Razor pipeline so the page model can render Razor markup +// — the standard ASP.NET production path for HTML responses. +app.MapRazorPages(); + app.MapStaticAssets(); app.Run(); diff --git a/frameworks/aspnet-minimal/meta.json b/frameworks/aspnet-minimal/meta.json index 44ea53a67..0089b7226 100644 --- a/frameworks/aspnet-minimal/meta.json +++ b/frameworks/aspnet-minimal/meta.json @@ -19,6 +19,7 @@ "static", "async-db", "crud", + "fortunes", "baseline-h2", "static-h2", "baseline-h2c", diff --git a/requests/fortunes-get.raw b/requests/fortunes-get.raw new file mode 100644 index 000000000..f518134a1 --- /dev/null +++ b/requests/fortunes-get.raw @@ -0,0 +1,3 @@ +GET /fortunes HTTP/1.1 +Host: localhost:8080 + diff --git a/scripts/benchmark.sh b/scripts/benchmark.sh index 3df3d1f5f..40872121c 100755 --- a/scripts/benchmark.sh +++ b/scripts/benchmark.sh @@ -183,7 +183,7 @@ fi # Start the postgres sidecar if any subscribed test needs it. need_pg=false -for t in async-db crud api-4 api-16 gateway-64 gateway-h3 production-stack; do +for t in async-db crud api-4 api-16 gateway-64 gateway-h3 production-stack fortunes; do if framework_subscribes_to "$t"; then need_pg=true; break; fi done $need_pg && postgres_start diff --git a/scripts/lib/framework.sh b/scripts/lib/framework.sh index 86336803d..f27e97a1f 100644 --- a/scripts/lib/framework.sh +++ b/scripts/lib/framework.sh @@ -75,7 +75,7 @@ framework_start() { # Profiles that exercise the database get DATABASE_URL + per-profile conn cap. case "$endpoint" in - async-db|crud|api-4|api-16) + async-db|crud|api-4|api-16|fortunes) args+=(-e "DATABASE_URL=$DATABASE_URL" -e "DATABASE_MAX_CONN=256") ;; esac diff --git a/scripts/lib/profiles.sh b/scripts/lib/profiles.sh index c203543a0..63c76d3b2 100644 --- a/scripts/lib/profiles.sh +++ b/scripts/lib/profiles.sh @@ -22,6 +22,7 @@ declare -A PROFILES=( [static]="1|200|0-31,64-95|1024,4096,6800|static" [async-db]="1|0|0-31,64-95|1024|async-db" [crud]="1|200|1-31,65-95|4096|crud" + [fortunes]="1|0|0-31,64-95|1024|fortunes" [baseline-h2]="1|0|0-31,64-95|256,1024|h2" [static-h2]="1|0|0-31,64-95|256,1024|static-h2" [baseline-h2c]="1|0|0-31,64-95|256,1024,4096|h2c" @@ -43,6 +44,7 @@ PROFILE_ORDER=( json json-comp json-tls upload api-4 api-16 static async-db crud + fortunes baseline-h2 static-h2 baseline-h2c json-h2c baseline-h3 static-h3 diff --git a/scripts/lib/tools/gcannon.sh b/scripts/lib/tools/gcannon.sh index d4049ac18..3fc2f1f53 100644 --- a/scripts/lib/tools/gcannon.sh +++ b/scripts/lib/tools/gcannon.sh @@ -45,6 +45,13 @@ gcannon_build_args() { --raw "$REQUESTS_DIR/async-db-5.raw,$REQUESTS_DIR/async-db-10.raw,$REQUESTS_DIR/async-db-20.raw,$REQUESTS_DIR/async-db-35.raw,$REQUESTS_DIR/async-db-50.raw" -c "$conns" -t "$THREADS" -d 10s -p "$pipeline" -r 25) ;; + fortunes) + # Single endpoint, fixed 12-row seed + 1 runtime-injected row. + # No --raw rotation: every request is the same GET; the per-request + # variance lives inside the handler (sort + render). + args=("http://localhost:$PORT/fortunes" + -c "$conns" -t "$THREADS" -d "$duration" -p "$pipeline") + ;; json) args=("http://localhost:$PORT" --raw "$REQUESTS_DIR/json-1.raw,$REQUESTS_DIR/json-5.raw,$REQUESTS_DIR/json-10.raw,$REQUESTS_DIR/json-15.raw,$REQUESTS_DIR/json-25.raw,$REQUESTS_DIR/json-40.raw,$REQUESTS_DIR/json-50.raw" diff --git a/scripts/validate.sh b/scripts/validate.sh index 980a2fbce..5587fbcbb 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -89,7 +89,7 @@ fi HARD_NOFILE=$(ulimit -Hn 2>/dev/null || echo 1048576) # Docker --ulimit nofile rejects "unlimited"; fall back to a large numeric cap [[ "$HARD_NOFILE" =~ ^[0-9]+$ ]] || HARD_NOFILE=1048576 -if has_test "async-db" || has_test "crud" || has_test "api-4" || has_test "api-16" || has_test "gateway-64" || has_test "gateway-h3" || has_test "production-stack"; then +if has_test "async-db" || has_test "crud" || has_test "api-4" || has_test "api-16" || has_test "gateway-64" || has_test "gateway-h3" || has_test "production-stack" || has_test "fortunes"; then docker_args=(-d --name "$CONTAINER_NAME" --network host --security-opt seccomp=unconfined --ulimit memlock=-1:-1 --ulimit nofile="$HARD_NOFILE:$HARD_NOFILE") else @@ -138,7 +138,7 @@ if [ "$ENGINE" = "io_uring" ]; then fi # Start Postgres sidecar if async-db is needed -if has_test "async-db" || has_test "crud" || has_test "api-4" || has_test "api-16" || has_test "gateway-64" || has_test "gateway-h3" || has_test "production-stack"; then +if has_test "async-db" || has_test "crud" || has_test "api-4" || has_test "api-16" || has_test "gateway-64" || has_test "gateway-h3" || has_test "production-stack" || has_test "fortunes"; then echo "[postgres] Starting Postgres sidecar for validation..." docker rm -f "$PG_CONTAINER" 2>/dev/null || true docker run -d --name "$PG_CONTAINER" --network host \ @@ -1025,6 +1025,73 @@ print(f'{count} {has_rating} {has_tags} {has_active_bool}') fi fi +# ───── Fortunes (GET /fortunes) — template-engine benchmark ───── +# +# Feature-based validation, not byte-exact. Engines disagree on whitespace +# and attribute formatting; what matters is that the rendered HTML actually +# loops the DB rows, includes the runtime-injected row, escapes user +# content, and is sized like a real page (not stripped to win the bench). + +if has_test "fortunes"; then + FORTUNES_DOCS="$DOCS_BASE/h1/isolated/fortunes/validation" + echo "[test] fortunes endpoint" + + body=$(curl -s --max-time 30 "http://localhost:$PORT/fortunes" || true) + + check_header "GET /fortunes Content-Type" "Content-Type" "text/html" "$FORTUNES_DOCS" \ + "http://localhost:$PORT/fortunes" + + if echo "$body" | grep -qi ''; then + echo " PASS [GET /fortunes ]" + PASS=$((PASS + 1)) + else + fail_with_link "[GET /fortunes ]: missing — layout/partial likely not rendered" "$FORTUNES_DOCS" + fi + + # Each row in the rendered table must produce a . 201 data rows are + # required (200 seeded + 1 runtime-injected); a header row is allowed + # but not required, so the band is 201–210 to absorb implementation- + # specific extras (footer rows, etc.). + tr_count=$(echo "$body" | grep -oi ' count=$tr_count]" + PASS=$((PASS + 1)) + else + fail_with_link "[GET /fortunes count]: expected 201–210, got $tr_count" "$FORTUNES_DOCS" + fi + + # Runtime-injected row text — proves the handler appended id=0 in memory + # rather than caching a pre-rendered page from the DB rows alone. + if echo "$body" | grep -qF 'Additional fortune added at request time.'; then + echo " PASS [GET /fortunes runtime-injected row]" + PASS=$((PASS + 1)) + else + fail_with_link "[GET /fortunes runtime-injected row]: missing 'Additional fortune added at request time.'" "$FORTUNES_DOCS" + fi + + # XSS escape — load-bearing check. Row 11 contains a raw ` as the message of row 11. The validator asserts: + +- Body contains `<script>` (the escape happened), AND +- Body does NOT contain the raw `