From 2e3a7fe6c61eca13588dd73a14e44cde6ab99d94 Mon Sep 17 00:00:00 2001 From: williamthome Date: Sun, 10 May 2026 22:45:33 -0300 Subject: [PATCH 01/22] Add roadrunner framework with baseline and pipeline endpoints --- frameworks/roadrunner/.gitignore | 4 ++ frameworks/roadrunner/Dockerfile | 14 +++++ frameworks/roadrunner/README.md | 25 +++++++++ .../roadrunner/config/rebar3_ssl.config | 11 ++++ frameworks/roadrunner/config/sys.config | 5 ++ frameworks/roadrunner/meta.json | 14 +++++ frameworks/roadrunner/rebar.config | 21 ++++++++ frameworks/roadrunner/rebar.lock | 8 +++ .../src/roadrunner_httparena.app.src | 15 ++++++ .../src/roadrunner_httparena_app.erl | 19 +++++++ .../src/roadrunner_httparena_handler.erl | 53 +++++++++++++++++++ .../src/roadrunner_httparena_sup.erl | 11 ++++ 12 files changed, 200 insertions(+) create mode 100644 frameworks/roadrunner/.gitignore create mode 100644 frameworks/roadrunner/Dockerfile create mode 100644 frameworks/roadrunner/README.md create mode 100644 frameworks/roadrunner/config/rebar3_ssl.config create mode 100644 frameworks/roadrunner/config/sys.config create mode 100644 frameworks/roadrunner/meta.json create mode 100644 frameworks/roadrunner/rebar.config create mode 100644 frameworks/roadrunner/rebar.lock create mode 100644 frameworks/roadrunner/src/roadrunner_httparena.app.src create mode 100644 frameworks/roadrunner/src/roadrunner_httparena_app.erl create mode 100644 frameworks/roadrunner/src/roadrunner_httparena_handler.erl create mode 100644 frameworks/roadrunner/src/roadrunner_httparena_sup.erl diff --git a/frameworks/roadrunner/.gitignore b/frameworks/roadrunner/.gitignore new file mode 100644 index 000000000..0fd9ded3a --- /dev/null +++ b/frameworks/roadrunner/.gitignore @@ -0,0 +1,4 @@ +_build/ +.rebar3/ +*.beam +*.swp diff --git a/frameworks/roadrunner/Dockerfile b/frameworks/roadrunner/Dockerfile new file mode 100644 index 000000000..8e02542cb --- /dev/null +++ b/frameworks/roadrunner/Dockerfile @@ -0,0 +1,14 @@ +FROM erlang:29.0.0.0-rc3-alpine AS build +RUN apk add --no-cache ca-certificates git +WORKDIR /src +COPY rebar.config ./ +COPY config ./config +COPY src ./src +ENV ERL_FLAGS="-config /src/config/rebar3_ssl" +RUN rebar3 as prod release + +FROM alpine:3.23 +RUN apk add --no-cache libstdc++ ncurses-libs openssl +COPY --from=build /src/_build/prod/rel/roadrunner_httparena /app +EXPOSE 8080 +CMD ["/app/bin/roadrunner_httparena", "foreground"] diff --git a/frameworks/roadrunner/README.md b/frameworks/roadrunner/README.md new file mode 100644 index 000000000..90560dc16 --- /dev/null +++ b/frameworks/roadrunner/README.md @@ -0,0 +1,25 @@ +# roadrunner + +[Roadrunner](https://github.com/arizona-framework/roadrunner) is the pure-Erlang HTTP/1.1 + WebSocket server from the Arizona framework. This entry packages a minimal OTP release wrapping roadrunner with the HttpArena endpoint contract. + +## Profiles + +Covered: + +- `baseline`, `pipelined`, `limited-conn` +- `json`, `json-comp`, `json-tls` +- `upload` +- `async-db`, `api-4`, `api-16` +- `echo-ws`, `echo-ws-pipeline` + +Deferred: + +- `static` — needs a static-file handler in roadrunner. +- `fortunes` — needs an HTML template story for roadrunner. +- `baseline-h2`, `static-h2`, `baseline-h2c`, `json-h2c`, `baseline-h3`, `static-h3` — roadrunner is HTTP/1.1 only today. +- `unary-grpc`, `stream-grpc` (+ TLS variants) — no gRPC stack. +- `crud`, `gateway-*`, `production-stack` — multi-container scenarios. + +## Build + +`docker build -t httparena-roadrunner frameworks/roadrunner` then run via `scripts/validate.sh roadrunner` from the repo root. diff --git a/frameworks/roadrunner/config/rebar3_ssl.config b/frameworks/roadrunner/config/rebar3_ssl.config new file mode 100644 index 000000000..cf2a44fc9 --- /dev/null +++ b/frameworks/roadrunner/config/rebar3_ssl.config @@ -0,0 +1,11 @@ +%% rebar3 BEAM TLS workaround for OTP 29 RC3 + Fastly hex.pm fingerprint +%% filter. Loaded by the Dockerfile build stage via +%% ERL_FLAGS="-config /src/config/rebar3_ssl" so it only affects rebar3 +%% during dep fetch, not the application's own ssl listener. +[ + {ssl, [ + {cacertfile, "/etc/ssl/certs/ca-certificates.crt"}, + {middlebox_comp_mode, false}, + {versions, ['tlsv1.2']} + ]} +]. diff --git a/frameworks/roadrunner/config/sys.config b/frameworks/roadrunner/config/sys.config new file mode 100644 index 000000000..b1e5e00a5 --- /dev/null +++ b/frameworks/roadrunner/config/sys.config @@ -0,0 +1,5 @@ +[ + {roadrunner_httparena, [ + {http_port, 8080} + ]} +]. diff --git a/frameworks/roadrunner/meta.json b/frameworks/roadrunner/meta.json new file mode 100644 index 000000000..8a585404b --- /dev/null +++ b/frameworks/roadrunner/meta.json @@ -0,0 +1,14 @@ +{ + "display_name": "roadrunner", + "language": "Erlang", + "type": "production", + "engine": "roadrunner", + "description": "Roadrunner: pure-Erlang HTTP/1.1 + WebSocket server from the Arizona framework.", + "repo": "https://github.com/arizona-framework/roadrunner", + "enabled": true, + "tests": [ + "baseline", + "pipelined" + ], + "maintainers": ["williamthome"] +} diff --git a/frameworks/roadrunner/rebar.config b/frameworks/roadrunner/rebar.config new file mode 100644 index 000000000..3860c51c1 --- /dev/null +++ b/frameworks/roadrunner/rebar.config @@ -0,0 +1,21 @@ +{erl_opts, [debug_info]}. + +{deps, [ + {roadrunner, {git, "https://github.com/arizona-framework/roadrunner.git", + {ref, "5522bddf8916faea6c9f74be2794e5b8d7113c62"}}} +]}. + +{relx, [ + {release, {roadrunner_httparena, "0.1.0"}, + [roadrunner_httparena, roadrunner]}, + {sys_config, "./config/sys.config"}, + {dev_mode, false}, + {include_erts, true}, + {extended_start_script, true} +]}. + +{profiles, [ + {prod, [ + {relx, [{mode, prod}]} + ]} +]}. diff --git a/frameworks/roadrunner/rebar.lock b/frameworks/roadrunner/rebar.lock new file mode 100644 index 000000000..81f1b0501 --- /dev/null +++ b/frameworks/roadrunner/rebar.lock @@ -0,0 +1,8 @@ +[{<<"roadrunner">>, + {git,"https://github.com/arizona-framework/roadrunner.git", + {ref,"5522bddf8916faea6c9f74be2794e5b8d7113c62"}}, + 0}, + {<<"telemetry">>, + {git,"https://github.com/beam-telemetry/telemetry.git", + {ref,"11462db509623be85c7acf3f15d0579d0d3f4a79"}}, + 1}]. diff --git a/frameworks/roadrunner/src/roadrunner_httparena.app.src b/frameworks/roadrunner/src/roadrunner_httparena.app.src new file mode 100644 index 000000000..a2e2dd8c1 --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena.app.src @@ -0,0 +1,15 @@ +{application, roadrunner_httparena, [ + {description, "HttpArena entry for the Roadrunner HTTP server"}, + {vsn, "0.1.0"}, + {registered, [roadrunner_httparena_sup]}, + {mod, {roadrunner_httparena_app, []}}, + {applications, [ + kernel, + stdlib, + ssl, + roadrunner + ]}, + {env, []}, + {modules, []}, + {licenses, ["Apache-2.0"]} +]}. diff --git a/frameworks/roadrunner/src/roadrunner_httparena_app.erl b/frameworks/roadrunner/src/roadrunner_httparena_app.erl new file mode 100644 index 000000000..94d510638 --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena_app.erl @@ -0,0 +1,19 @@ +-module(roadrunner_httparena_app). +-behaviour(application). + +-export([start/2, stop/1]). + +start(_StartType, _StartArgs) -> + {ok, SupPid} = roadrunner_httparena_sup:start_link(), + HttpPort = application:get_env(roadrunner_httparena, http_port, 8080), + Routes = roadrunner_httparena_handler:routes(), + {ok, _} = roadrunner:start_listener(httparena_http, #{ + port => HttpPort, + routes => Routes, + %% 25 MB headroom for the upload profile (validator goes up to 20 MB). + max_content_length => 26214400 + }), + {ok, SupPid}. + +stop(_State) -> + ok. diff --git a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl new file mode 100644 index 000000000..ffa80bf03 --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl @@ -0,0 +1,53 @@ +-module(roadrunner_httparena_handler). +-behaviour(roadrunner_handler). + +-export([routes/0]). +-export([handle/1]). + +-spec routes() -> [roadrunner_router:route()]. +routes() -> + [ + {~"/baseline11", ?MODULE, undefined}, + {~"/pipeline", ?MODULE, undefined} + ]. + +-spec handle(roadrunner_req:request()) -> roadrunner_handler:result(). +handle(Req) -> + handle_route(roadrunner_req:path(Req), Req). + +handle_route(~"/baseline11", Req) -> + baseline11(Req); +handle_route(~"/pipeline", Req) -> + {roadrunner_resp:text(200, ~"ok"), Req}; +handle_route(_, Req) -> + {roadrunner_resp:not_found(), Req}. + +baseline11(Req) -> + A = qs_int(~"a", Req, 0), + B = qs_int(~"b", Req, 0), + {BodyN, Req2} = + case roadrunner_req:method(Req) of + ~"POST" -> + {ok, Body, ReqR} = roadrunner_req:read_body(Req), + {body_int(Body), ReqR}; + _ -> + {0, Req} + end, + {roadrunner_resp:text(200, integer_to_binary(A + B + BodyN)), Req2}. + +qs_int(Key, Req, Default) -> + case lists:keyfind(Key, 1, roadrunner_req:parse_qs(Req)) of + {Key, V} when is_binary(V) -> bin_int(V, Default); + _ -> Default + end. + +bin_int(<<>>, Default) -> + Default; +bin_int(Bin, Default) -> + case string:to_integer(Bin) of + {N, _} when is_integer(N) -> N; + _ -> Default + end. + +body_int(<<>>) -> 0; +body_int(Bin) -> bin_int(Bin, 0). diff --git a/frameworks/roadrunner/src/roadrunner_httparena_sup.erl b/frameworks/roadrunner/src/roadrunner_httparena_sup.erl new file mode 100644 index 000000000..7424f056b --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena_sup.erl @@ -0,0 +1,11 @@ +-module(roadrunner_httparena_sup). +-behaviour(supervisor). + +-export([start_link/0, init/1]). + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +init([]) -> + SupFlags = #{strategy => one_for_one, intensity => 5, period => 10}, + {ok, {SupFlags, []}}. From 258aadaa0c6941d736ee215162f950d7cc118e9a Mon Sep 17 00:00:00 2001 From: williamthome Date: Sun, 10 May 2026 22:48:02 -0300 Subject: [PATCH 02/22] Add JSON endpoint and dataset loader to roadrunner --- frameworks/roadrunner/meta.json | 3 ++- .../src/roadrunner_httparena_app.erl | 1 + .../src/roadrunner_httparena_dataset.erl | 25 +++++++++++++++++++ .../src/roadrunner_httparena_handler.erl | 25 ++++++++++++++++++- 4 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 frameworks/roadrunner/src/roadrunner_httparena_dataset.erl diff --git a/frameworks/roadrunner/meta.json b/frameworks/roadrunner/meta.json index 8a585404b..fd47cd6a3 100644 --- a/frameworks/roadrunner/meta.json +++ b/frameworks/roadrunner/meta.json @@ -8,7 +8,8 @@ "enabled": true, "tests": [ "baseline", - "pipelined" + "pipelined", + "json" ], "maintainers": ["williamthome"] } diff --git a/frameworks/roadrunner/src/roadrunner_httparena_app.erl b/frameworks/roadrunner/src/roadrunner_httparena_app.erl index 94d510638..6bcc479ad 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_app.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_app.erl @@ -5,6 +5,7 @@ start(_StartType, _StartArgs) -> {ok, SupPid} = roadrunner_httparena_sup:start_link(), + ok = roadrunner_httparena_dataset:load(), HttpPort = application:get_env(roadrunner_httparena, http_port, 8080), Routes = roadrunner_httparena_handler:routes(), {ok, _} = roadrunner:start_listener(httparena_http, #{ diff --git a/frameworks/roadrunner/src/roadrunner_httparena_dataset.erl b/frameworks/roadrunner/src/roadrunner_httparena_dataset.erl new file mode 100644 index 000000000..851d5c902 --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena_dataset.erl @@ -0,0 +1,25 @@ +-module(roadrunner_httparena_dataset). + +-export([load/0, items/0]). + +-define(KEY, {?MODULE, items}). + +-spec load() -> ok. +load() -> + Path = path(), + {ok, Raw} = file:read_file(Path), + Items = json:decode(Raw), + persistent_term:put(?KEY, Items), + ok. + +-spec items() -> [map()]. +items() -> + persistent_term:get(?KEY). + +path() -> + case os:getenv("DATASET_PATH") of + false -> + application:get_env(roadrunner_httparena, dataset_path, "/data/dataset.json"); + Env -> + Env + end. diff --git a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl index ffa80bf03..ed0df9e4d 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl @@ -8,7 +8,8 @@ routes() -> [ {~"/baseline11", ?MODULE, undefined}, - {~"/pipeline", ?MODULE, undefined} + {~"/pipeline", ?MODULE, undefined}, + {~"/json/:count", ?MODULE, undefined} ]. -spec handle(roadrunner_req:request()) -> roadrunner_handler:result(). @@ -19,6 +20,8 @@ handle_route(~"/baseline11", Req) -> baseline11(Req); handle_route(~"/pipeline", Req) -> {roadrunner_resp:text(200, ~"ok"), Req}; +handle_route(<<"/json/", _/binary>>, Req) -> + json_endpoint(Req); handle_route(_, Req) -> {roadrunner_resp:not_found(), Req}. @@ -35,6 +38,26 @@ baseline11(Req) -> end, {roadrunner_resp:text(200, integer_to_binary(A + B + BodyN)), Req2}. +json_endpoint(Req) -> + Count = binding_int(~"count", Req, 0), + M = qs_int(~"m", Req, 1), + All = roadrunner_httparena_dataset:items(), + Items = lists:sublist(All, max(0, Count)), + Processed = [add_total(I, M) || I <- Items], + Body = #{~"items" => Processed, ~"count" => length(Processed)}, + {roadrunner_resp:json(200, Body), Req}. + +add_total(Item, M) -> + Price = maps:get(~"price", Item), + Qty = maps:get(~"quantity", Item), + Item#{~"total" => Price * Qty * M}. + +binding_int(Key, Req, Default) -> + case roadrunner_req:bindings(Req) of + #{Key := V} when is_binary(V) -> bin_int(V, Default); + _ -> Default + end. + qs_int(Key, Req, Default) -> case lists:keyfind(Key, 1, roadrunner_req:parse_qs(Req)) of {Key, V} when is_binary(V) -> bin_int(V, Default); From 093132fe5f230ce189691ac0bbb3f0992e26daeb Mon Sep 17 00:00:00 2001 From: williamthome Date: Sun, 10 May 2026 22:49:00 -0300 Subject: [PATCH 03/22] Enable gzip compression middleware for json-comp profile --- frameworks/roadrunner/meta.json | 3 ++- frameworks/roadrunner/src/roadrunner_httparena_app.erl | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/frameworks/roadrunner/meta.json b/frameworks/roadrunner/meta.json index fd47cd6a3..365e54630 100644 --- a/frameworks/roadrunner/meta.json +++ b/frameworks/roadrunner/meta.json @@ -9,7 +9,8 @@ "tests": [ "baseline", "pipelined", - "json" + "json", + "json-comp" ], "maintainers": ["williamthome"] } diff --git a/frameworks/roadrunner/src/roadrunner_httparena_app.erl b/frameworks/roadrunner/src/roadrunner_httparena_app.erl index 6bcc479ad..dc93b7bbc 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_app.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_app.erl @@ -11,6 +11,7 @@ start(_StartType, _StartArgs) -> {ok, _} = roadrunner:start_listener(httparena_http, #{ port => HttpPort, routes => Routes, + middlewares => [roadrunner_compress], %% 25 MB headroom for the upload profile (validator goes up to 20 MB). max_content_length => 26214400 }), From 19dfa4fd4cac6a7039852baa85a532d0e073c9ba Mon Sep 17 00:00:00 2001 From: williamthome Date: Sun, 10 May 2026 22:50:04 -0300 Subject: [PATCH 04/22] Add TLS listener for json-tls profile --- frameworks/roadrunner/meta.json | 3 +- .../src/roadrunner_httparena_app.erl | 29 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/frameworks/roadrunner/meta.json b/frameworks/roadrunner/meta.json index 365e54630..0430fc8a2 100644 --- a/frameworks/roadrunner/meta.json +++ b/frameworks/roadrunner/meta.json @@ -10,7 +10,8 @@ "baseline", "pipelined", "json", - "json-comp" + "json-comp", + "json-tls" ], "maintainers": ["williamthome"] } diff --git a/frameworks/roadrunner/src/roadrunner_httparena_app.erl b/frameworks/roadrunner/src/roadrunner_httparena_app.erl index dc93b7bbc..7759e54c3 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_app.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_app.erl @@ -6,8 +6,8 @@ start(_StartType, _StartArgs) -> {ok, SupPid} = roadrunner_httparena_sup:start_link(), ok = roadrunner_httparena_dataset:load(), - HttpPort = application:get_env(roadrunner_httparena, http_port, 8080), Routes = roadrunner_httparena_handler:routes(), + HttpPort = application:get_env(roadrunner_httparena, http_port, 8080), {ok, _} = roadrunner:start_listener(httparena_http, #{ port => HttpPort, routes => Routes, @@ -15,7 +15,34 @@ start(_StartType, _StartArgs) -> %% 25 MB headroom for the upload profile (validator goes up to 20 MB). max_content_length => 26214400 }), + case tls_opts() of + {ok, TlsOpts} -> + TlsPort = application:get_env(roadrunner_httparena, tls_port, 8081), + {ok, _} = roadrunner:start_listener(httparena_tls, #{ + port => TlsPort, + routes => Routes, + middlewares => [roadrunner_compress], + max_content_length => 26214400, + tls => TlsOpts + }); + skip -> + ok + end, {ok, SupPid}. stop(_State) -> ok. + +tls_opts() -> + Cert = env_path("TLS_CERT_PATH", tls_cert, "/certs/server.crt"), + Key = env_path("TLS_KEY_PATH", tls_key, "/certs/server.key"), + case filelib:is_regular(Cert) andalso filelib:is_regular(Key) of + true -> {ok, [{certfile, Cert}, {keyfile, Key}]}; + false -> skip + end. + +env_path(EnvVar, AppKey, Default) -> + case os:getenv(EnvVar) of + false -> application:get_env(roadrunner_httparena, AppKey, Default); + V -> V + end. From 4bbb61df45a89f37df20ad1778d63d9a6f036270 Mon Sep 17 00:00:00 2001 From: williamthome Date: Sun, 10 May 2026 22:51:08 -0300 Subject: [PATCH 05/22] Add upload endpoint for upload profile --- frameworks/roadrunner/meta.json | 3 ++- .../roadrunner/src/roadrunner_httparena_handler.erl | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/frameworks/roadrunner/meta.json b/frameworks/roadrunner/meta.json index 0430fc8a2..7dcaadbfa 100644 --- a/frameworks/roadrunner/meta.json +++ b/frameworks/roadrunner/meta.json @@ -11,7 +11,8 @@ "pipelined", "json", "json-comp", - "json-tls" + "json-tls", + "upload" ], "maintainers": ["williamthome"] } diff --git a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl index ed0df9e4d..dde88a7fb 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl @@ -9,7 +9,8 @@ routes() -> [ {~"/baseline11", ?MODULE, undefined}, {~"/pipeline", ?MODULE, undefined}, - {~"/json/:count", ?MODULE, undefined} + {~"/json/:count", ?MODULE, undefined}, + {~"/upload", ?MODULE, undefined} ]. -spec handle(roadrunner_req:request()) -> roadrunner_handler:result(). @@ -22,9 +23,15 @@ handle_route(~"/pipeline", Req) -> {roadrunner_resp:text(200, ~"ok"), Req}; handle_route(<<"/json/", _/binary>>, Req) -> json_endpoint(Req); +handle_route(~"/upload", Req) -> + upload_endpoint(Req); handle_route(_, Req) -> {roadrunner_resp:not_found(), Req}. +upload_endpoint(Req) -> + {ok, Body, Req2} = roadrunner_req:read_body(Req), + {roadrunner_resp:text(200, integer_to_binary(byte_size(Body))), Req2}. + baseline11(Req) -> A = qs_int(~"a", Req, 0), B = qs_int(~"b", Req, 0), From 401fa4bb1e3f0611274e91f711dcc15618488a7b Mon Sep 17 00:00:00 2001 From: williamthome Date: Sun, 10 May 2026 22:51:44 -0300 Subject: [PATCH 06/22] Add sasl to applications list to silence relx upgrade warning --- frameworks/roadrunner/src/roadrunner_httparena.app.src | 1 + 1 file changed, 1 insertion(+) diff --git a/frameworks/roadrunner/src/roadrunner_httparena.app.src b/frameworks/roadrunner/src/roadrunner_httparena.app.src index a2e2dd8c1..e0534de7a 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena.app.src +++ b/frameworks/roadrunner/src/roadrunner_httparena.app.src @@ -6,6 +6,7 @@ {applications, [ kernel, stdlib, + sasl, ssl, roadrunner ]}, From fa5791f16626747b2ad9e4f0518afa0df7b05ef4 Mon Sep 17 00:00:00 2001 From: williamthome Date: Sun, 10 May 2026 22:54:02 -0300 Subject: [PATCH 07/22] Add WebSocket echo handler for echo-ws profiles --- frameworks/roadrunner/meta.json | 4 +++- .../roadrunner/src/roadrunner_httparena_handler.erl | 5 ++++- frameworks/roadrunner/src/roadrunner_httparena_ws.erl | 11 +++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 frameworks/roadrunner/src/roadrunner_httparena_ws.erl diff --git a/frameworks/roadrunner/meta.json b/frameworks/roadrunner/meta.json index 7dcaadbfa..4e7b02ee3 100644 --- a/frameworks/roadrunner/meta.json +++ b/frameworks/roadrunner/meta.json @@ -12,7 +12,9 @@ "json", "json-comp", "json-tls", - "upload" + "upload", + "echo-ws", + "echo-ws-pipeline" ], "maintainers": ["williamthome"] } diff --git a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl index dde88a7fb..bc2d484cd 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl @@ -10,7 +10,8 @@ routes() -> {~"/baseline11", ?MODULE, undefined}, {~"/pipeline", ?MODULE, undefined}, {~"/json/:count", ?MODULE, undefined}, - {~"/upload", ?MODULE, undefined} + {~"/upload", ?MODULE, undefined}, + {~"/ws", ?MODULE, undefined} ]. -spec handle(roadrunner_req:request()) -> roadrunner_handler:result(). @@ -25,6 +26,8 @@ handle_route(<<"/json/", _/binary>>, Req) -> json_endpoint(Req); handle_route(~"/upload", Req) -> upload_endpoint(Req); +handle_route(~"/ws", Req) -> + {{websocket, roadrunner_httparena_ws, undefined}, Req}; handle_route(_, Req) -> {roadrunner_resp:not_found(), Req}. diff --git a/frameworks/roadrunner/src/roadrunner_httparena_ws.erl b/frameworks/roadrunner/src/roadrunner_httparena_ws.erl new file mode 100644 index 000000000..fd6a95f4a --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena_ws.erl @@ -0,0 +1,11 @@ +-module(roadrunner_httparena_ws). +-behaviour(roadrunner_ws_handler). + +-export([handle_frame/2]). + +handle_frame(#{opcode := text, payload := Payload}, State) -> + {reply, [{text, Payload}], State}; +handle_frame(#{opcode := binary, payload := Payload}, State) -> + {reply, [{binary, Payload}], State}; +handle_frame(_, State) -> + {ok, State}. From c5ee460b628b1d0b3dde04a6bc7c8a64ddce0670 Mon Sep 17 00:00:00 2001 From: williamthome Date: Sun, 10 May 2026 23:02:18 -0300 Subject: [PATCH 08/22] Add async-db endpoint and Postgres connection pool --- frameworks/roadrunner/meta.json | 3 + frameworks/roadrunner/rebar.config | 4 +- frameworks/roadrunner/rebar.lock | 15 +++- .../src/roadrunner_httparena.app.src | 2 + .../src/roadrunner_httparena_app.erl | 1 + .../src/roadrunner_httparena_db.erl | 71 +++++++++++++++++++ .../src/roadrunner_httparena_handler.erl | 37 ++++++++++ 7 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 frameworks/roadrunner/src/roadrunner_httparena_db.erl diff --git a/frameworks/roadrunner/meta.json b/frameworks/roadrunner/meta.json index 4e7b02ee3..ce2c46663 100644 --- a/frameworks/roadrunner/meta.json +++ b/frameworks/roadrunner/meta.json @@ -13,6 +13,9 @@ "json-comp", "json-tls", "upload", + "async-db", + "api-4", + "api-16", "echo-ws", "echo-ws-pipeline" ], diff --git a/frameworks/roadrunner/rebar.config b/frameworks/roadrunner/rebar.config index 3860c51c1..b46404baa 100644 --- a/frameworks/roadrunner/rebar.config +++ b/frameworks/roadrunner/rebar.config @@ -2,7 +2,9 @@ {deps, [ {roadrunner, {git, "https://github.com/arizona-framework/roadrunner.git", - {ref, "5522bddf8916faea6c9f74be2794e5b8d7113c62"}}} + {ref, "5522bddf8916faea6c9f74be2794e5b8d7113c62"}}}, + {epgsql, "4.8.0"}, + {pooler, "1.6.0"} ]}. {relx, [ diff --git a/frameworks/roadrunner/rebar.lock b/frameworks/roadrunner/rebar.lock index 81f1b0501..0dcfc84b1 100644 --- a/frameworks/roadrunner/rebar.lock +++ b/frameworks/roadrunner/rebar.lock @@ -1,8 +1,19 @@ -[{<<"roadrunner">>, +{"1.2.0", +[{<<"epgsql">>,{pkg,<<"epgsql">>,<<"4.8.0">>},0}, + {<<"pooler">>,{pkg,<<"pooler">>,<<"1.6.0">>},0}, + {<<"roadrunner">>, {git,"https://github.com/arizona-framework/roadrunner.git", {ref,"5522bddf8916faea6c9f74be2794e5b8d7113c62"}}, 0}, {<<"telemetry">>, {git,"https://github.com/beam-telemetry/telemetry.git", {ref,"11462db509623be85c7acf3f15d0579d0d3f4a79"}}, - 1}]. + 1}]}. +[ +{pkg_hash,[ + {<<"epgsql">>, <<"C491B141B8C37BCE7B67F2079F168F339BB374A7CF9A286CB3B40AD1CD3FABE5">>}, + {<<"pooler">>, <<"F4F33C94AB3AB82565A2E31CEA9EFE4149A160651F3707A0A2669BC54AAF81C8">>}]}, +{pkg_hash_ext,[ + {<<"epgsql">>, <<"00E550006A62FB439FC7E879419443C889C34605E9B9DC406029D8BAA1AD79D8">>}, + {<<"pooler">>, <<"748C988FD2928DE9577C882A49621863CAB57809E3E1A88A14C9D3B55C6AB877">>}]} +]. diff --git a/frameworks/roadrunner/src/roadrunner_httparena.app.src b/frameworks/roadrunner/src/roadrunner_httparena.app.src index e0534de7a..fcde85e21 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena.app.src +++ b/frameworks/roadrunner/src/roadrunner_httparena.app.src @@ -8,6 +8,8 @@ stdlib, sasl, ssl, + epgsql, + pooler, roadrunner ]}, {env, []}, diff --git a/frameworks/roadrunner/src/roadrunner_httparena_app.erl b/frameworks/roadrunner/src/roadrunner_httparena_app.erl index 7759e54c3..36a43cc57 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_app.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_app.erl @@ -6,6 +6,7 @@ start(_StartType, _StartArgs) -> {ok, SupPid} = roadrunner_httparena_sup:start_link(), ok = roadrunner_httparena_dataset:load(), + _ = roadrunner_httparena_db:start_pool(), Routes = roadrunner_httparena_handler:routes(), HttpPort = application:get_env(roadrunner_httparena, http_port, 8080), {ok, _} = roadrunner:start_listener(httparena_http, #{ diff --git a/frameworks/roadrunner/src/roadrunner_httparena_db.erl b/frameworks/roadrunner/src/roadrunner_httparena_db.erl new file mode 100644 index 000000000..c9f814633 --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena_db.erl @@ -0,0 +1,71 @@ +-module(roadrunner_httparena_db). + +-export([start_pool/0, query/2]). + +-define(POOL, httparena_pg). + +-spec start_pool() -> ok | disabled. +start_pool() -> + case os:getenv("DATABASE_URL") of + false -> + disabled; + Url -> + ConnMap = parse_url(Url), + Size = pool_size(), + PoolConfig = [ + {name, ?POOL}, + {init_count, Size}, + {max_count, Size}, + {start_mfa, {epgsql, connect, [ConnMap]}} + ], + {ok, _Pid} = pooler:new_pool(PoolConfig), + ok + end. + +-spec query(iodata(), [term()]) -> + {ok, list(), list()} | {ok, non_neg_integer()} | {error, term()}. +query(Sql, Params) -> + case pooler:take_member(?POOL) of + Conn when is_pid(Conn) -> + try + epgsql:equery(Conn, Sql, Params) + after + pooler:return_member(?POOL, Conn, ok) + end; + error_no_members -> + {error, no_members} + end. + +parse_url(Url) -> + Parsed = uri_string:parse(Url), + {User, Pass} = split_userinfo(maps:get(userinfo, Parsed, "")), + Database = strip_slash(maps:get(path, Parsed, "/")), + #{ + host => maps:get(host, Parsed, "localhost"), + port => maps:get(port, Parsed, 5432), + username => User, + password => Pass, + database => Database + }. + +split_userinfo("") -> + {"", ""}; +split_userinfo(UserInfo) -> + case string:split(UserInfo, ":") of + [U, P] -> {U, P}; + [U] -> {U, ""} + end. + +strip_slash("/" ++ Rest) -> Rest; +strip_slash(Other) -> Other. + +pool_size() -> + case os:getenv("DATABASE_MAX_CONN") of + false -> + 32; + S -> + case string:to_integer(S) of + {N, _} when is_integer(N), N > 0 -> min(N, 256); + _ -> 32 + end + end. diff --git a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl index bc2d484cd..c25420be7 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl @@ -11,6 +11,7 @@ routes() -> {~"/pipeline", ?MODULE, undefined}, {~"/json/:count", ?MODULE, undefined}, {~"/upload", ?MODULE, undefined}, + {~"/async-db", ?MODULE, undefined}, {~"/ws", ?MODULE, undefined} ]. @@ -26,6 +27,8 @@ handle_route(<<"/json/", _/binary>>, Req) -> json_endpoint(Req); handle_route(~"/upload", Req) -> upload_endpoint(Req); +handle_route(~"/async-db", Req) -> + async_db_endpoint(Req); handle_route(~"/ws", Req) -> {{websocket, roadrunner_httparena_ws, undefined}, Req}; handle_route(_, Req) -> @@ -35,6 +38,40 @@ upload_endpoint(Req) -> {ok, Body, Req2} = roadrunner_req:read_body(Req), {roadrunner_resp:text(200, integer_to_binary(byte_size(Body))), Req2}. +async_db_endpoint(Req) -> + Min = qs_int(~"min", Req, 10), + Max = qs_int(~"max", Req, 50), + Limit = clamp(qs_int(~"limit", Req, 50), 1, 50), + Sql = ~""" + SELECT id, name, category, price, quantity, active, tags, + rating_score, rating_count + FROM items + WHERE price BETWEEN $1 AND $2 LIMIT $3 + """, + Items = + case roadrunner_httparena_db:query(Sql, [Min, Max, Limit]) of + {ok, _Cols, Rows} -> [row_to_json(R) || R <- Rows]; + _ -> [] + end, + Body = #{~"count" => length(Items), ~"items" => Items}, + {roadrunner_resp:json(200, Body), Req}. + +row_to_json({Id, Name, Cat, Price, Qty, Active, TagsJsonb, RScore, RCount}) -> + #{ + ~"id" => Id, + ~"name" => Name, + ~"category" => Cat, + ~"price" => Price, + ~"quantity" => Qty, + ~"active" => Active, + ~"tags" => json:decode(TagsJsonb), + ~"rating" => #{~"score" => RScore, ~"count" => RCount} + }. + +clamp(N, Lo, _Hi) when N < Lo -> Lo; +clamp(N, _Lo, Hi) when N > Hi -> Hi; +clamp(N, _Lo, _Hi) -> N. + baseline11(Req) -> A = qs_int(~"a", Req, 0), B = qs_int(~"b", Req, 0), From 930795e1de0a4232b4d78c6a8b287bae1afb12ef Mon Sep 17 00:00:00 2001 From: williamthome Date: Sun, 10 May 2026 23:06:12 -0300 Subject: [PATCH 09/22] Add HTTP/2 listener for baseline-h2 profile --- frameworks/roadrunner/README.md | 16 +++++++++------- frameworks/roadrunner/meta.json | 1 + .../roadrunner/src/roadrunner_httparena_app.erl | 9 +++++++++ .../src/roadrunner_httparena_handler.erl | 7 +++++-- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/frameworks/roadrunner/README.md b/frameworks/roadrunner/README.md index 90560dc16..4c56c9272 100644 --- a/frameworks/roadrunner/README.md +++ b/frameworks/roadrunner/README.md @@ -1,6 +1,6 @@ # roadrunner -[Roadrunner](https://github.com/arizona-framework/roadrunner) is the pure-Erlang HTTP/1.1 + WebSocket server from the Arizona framework. This entry packages a minimal OTP release wrapping roadrunner with the HttpArena endpoint contract. +[Roadrunner](https://github.com/arizona-framework/roadrunner) is the pure-Erlang HTTP/1.1 + HTTP/2 + WebSocket server from the Arizona framework. This entry packages a minimal OTP release wrapping roadrunner with the HttpArena endpoint contract. ## Profiles @@ -10,15 +10,17 @@ Covered: - `json`, `json-comp`, `json-tls` - `upload` - `async-db`, `api-4`, `api-16` +- `baseline-h2` - `echo-ws`, `echo-ws-pipeline` -Deferred: +Deferred (tracked under [HttpArena coverage gaps](https://github.com/arizona-framework/roadrunner/blob/main/docs/roadmap.md) in the roadrunner roadmap): -- `static` — needs a static-file handler in roadrunner. -- `fortunes` — needs an HTML template story for roadrunner. -- `baseline-h2`, `static-h2`, `baseline-h2c`, `json-h2c`, `baseline-h3`, `static-h3` — roadrunner is HTTP/1.1 only today. -- `unary-grpc`, `stream-grpc` (+ TLS variants) — no gRPC stack. -- `crud`, `gateway-*`, `production-stack` — multi-container scenarios. +- `static`, `static-h2`: roadrunner has a static handler; the bench app entry needs to wire it up and add gzip-sibling serving. +- `fortunes`, `crud`: bench-app endpoints, not roadrunner gaps. +- `baseline-h2c`, `json-h2c`: roadrunner is h2-over-TLS-only today; h2c (cleartext h2) is a roadrunner-side gap. +- `baseline-h3`, `static-h3`: roadrunner has no HTTP/3 stack yet. +- `unary-grpc`, `stream-grpc`, TLS variants: no gRPC stack. +- `gateway-64`, `gateway-h3`, `production-stack`: reverse-proxy multi-container scenarios; out of scope for the single-framework entry. ## Build diff --git a/frameworks/roadrunner/meta.json b/frameworks/roadrunner/meta.json index ce2c46663..0cb109b04 100644 --- a/frameworks/roadrunner/meta.json +++ b/frameworks/roadrunner/meta.json @@ -16,6 +16,7 @@ "async-db", "api-4", "api-16", + "baseline-h2", "echo-ws", "echo-ws-pipeline" ], diff --git a/frameworks/roadrunner/src/roadrunner_httparena_app.erl b/frameworks/roadrunner/src/roadrunner_httparena_app.erl index 36a43cc57..91ccdb63a 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_app.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_app.erl @@ -25,6 +25,15 @@ start(_StartType, _StartArgs) -> middlewares => [roadrunner_compress], max_content_length => 26214400, tls => TlsOpts + }), + H2Port = application:get_env(roadrunner_httparena, h2_port, 8443), + H2TlsOpts = [{alpn_preferred_protocols, [~"h2", ~"http/1.1"]} | TlsOpts], + {ok, _} = roadrunner:start_listener(httparena_h2, #{ + port => H2Port, + routes => Routes, + middlewares => [roadrunner_compress], + max_content_length => 26214400, + tls => H2TlsOpts }); skip -> ok diff --git a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl index c25420be7..60e9bac47 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl @@ -8,6 +8,7 @@ routes() -> [ {~"/baseline11", ?MODULE, undefined}, + {~"/baseline2", ?MODULE, undefined}, {~"/pipeline", ?MODULE, undefined}, {~"/json/:count", ?MODULE, undefined}, {~"/upload", ?MODULE, undefined}, @@ -20,7 +21,9 @@ handle(Req) -> handle_route(roadrunner_req:path(Req), Req). handle_route(~"/baseline11", Req) -> - baseline11(Req); + baseline(Req); +handle_route(~"/baseline2", Req) -> + baseline(Req); handle_route(~"/pipeline", Req) -> {roadrunner_resp:text(200, ~"ok"), Req}; handle_route(<<"/json/", _/binary>>, Req) -> @@ -72,7 +75,7 @@ clamp(N, Lo, _Hi) when N < Lo -> Lo; clamp(N, _Lo, Hi) when N > Hi -> Hi; clamp(N, _Lo, _Hi) -> N. -baseline11(Req) -> +baseline(Req) -> A = qs_int(~"a", Req, 0), B = qs_int(~"b", Req, 0), {BodyN, Req2} = From 67e4d1f1169b4895ad8da6258a6279e45d8f42ac Mon Sep 17 00:00:00 2001 From: williamthome Date: Sun, 10 May 2026 23:10:09 -0300 Subject: [PATCH 10/22] Add limited-conn profile and HTTP/2 to description --- frameworks/roadrunner/meta.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frameworks/roadrunner/meta.json b/frameworks/roadrunner/meta.json index 0cb109b04..fbad1b667 100644 --- a/frameworks/roadrunner/meta.json +++ b/frameworks/roadrunner/meta.json @@ -3,12 +3,13 @@ "language": "Erlang", "type": "production", "engine": "roadrunner", - "description": "Roadrunner: pure-Erlang HTTP/1.1 + WebSocket server from the Arizona framework.", + "description": "Roadrunner: pure-Erlang HTTP/1.1 + HTTP/2 + WebSocket server from the Arizona framework.", "repo": "https://github.com/arizona-framework/roadrunner", "enabled": true, "tests": [ "baseline", "pipelined", + "limited-conn", "json", "json-comp", "json-tls", From 33c1d0ff7293d98b651a486ec4d01ddbb5fa4629 Mon Sep 17 00:00:00 2001 From: williamthome Date: Sun, 10 May 2026 23:13:14 -0300 Subject: [PATCH 11/22] Add static file route via roadrunner_static --- frameworks/roadrunner/README.md | 3 ++- frameworks/roadrunner/meta.json | 1 + .../roadrunner/src/roadrunner_httparena_handler.erl | 9 ++++++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/frameworks/roadrunner/README.md b/frameworks/roadrunner/README.md index 4c56c9272..48245a591 100644 --- a/frameworks/roadrunner/README.md +++ b/frameworks/roadrunner/README.md @@ -10,12 +10,13 @@ Covered: - `json`, `json-comp`, `json-tls` - `upload` - `async-db`, `api-4`, `api-16` +- `static` - `baseline-h2` - `echo-ws`, `echo-ws-pipeline` Deferred (tracked under [HttpArena coverage gaps](https://github.com/arizona-framework/roadrunner/blob/main/docs/roadmap.md) in the roadrunner roadmap): -- `static`, `static-h2`: roadrunner has a static handler; the bench app entry needs to wire it up and add gzip-sibling serving. +- `static-h2`: needs `{sendfile, _}` over h2 in roadrunner. - `fortunes`, `crud`: bench-app endpoints, not roadrunner gaps. - `baseline-h2c`, `json-h2c`: roadrunner is h2-over-TLS-only today; h2c (cleartext h2) is a roadrunner-side gap. - `baseline-h3`, `static-h3`: roadrunner has no HTTP/3 stack yet. diff --git a/frameworks/roadrunner/meta.json b/frameworks/roadrunner/meta.json index fbad1b667..744bb0ec7 100644 --- a/frameworks/roadrunner/meta.json +++ b/frameworks/roadrunner/meta.json @@ -17,6 +17,7 @@ "async-db", "api-4", "api-16", + "static", "baseline-h2", "echo-ws", "echo-ws-pipeline" diff --git a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl index 60e9bac47..a4e9c6999 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl @@ -13,9 +13,16 @@ routes() -> {~"/json/:count", ?MODULE, undefined}, {~"/upload", ?MODULE, undefined}, {~"/async-db", ?MODULE, undefined}, - {~"/ws", ?MODULE, undefined} + {~"/ws", ?MODULE, undefined}, + {~"/static/*path", roadrunner_static, #{dir => static_dir()}} ]. +static_dir() -> + case os:getenv("STATIC_DIR") of + false -> ~"/data/static"; + D -> iolist_to_binary(D) + end. + -spec handle(roadrunner_req:request()) -> roadrunner_handler:result(). handle(Req) -> handle_route(roadrunner_req:path(Req), Req). From cacab85c26c00d4f49f7f3507ba7865f40666651 Mon Sep 17 00:00:00 2001 From: williamthome Date: Sun, 10 May 2026 23:18:16 -0300 Subject: [PATCH 12/22] Add fortunes endpoint with HTML escaping --- frameworks/roadrunner/meta.json | 1 + .../src/roadrunner_httparena_handler.erl | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/frameworks/roadrunner/meta.json b/frameworks/roadrunner/meta.json index 744bb0ec7..a0a7cd8b9 100644 --- a/frameworks/roadrunner/meta.json +++ b/frameworks/roadrunner/meta.json @@ -18,6 +18,7 @@ "api-4", "api-16", "static", + "fortunes", "baseline-h2", "echo-ws", "echo-ws-pipeline" diff --git a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl index a4e9c6999..e28575992 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl @@ -13,6 +13,7 @@ routes() -> {~"/json/:count", ?MODULE, undefined}, {~"/upload", ?MODULE, undefined}, {~"/async-db", ?MODULE, undefined}, + {~"/fortunes", ?MODULE, undefined}, {~"/ws", ?MODULE, undefined}, {~"/static/*path", roadrunner_static, #{dir => static_dir()}} ]. @@ -39,6 +40,8 @@ handle_route(~"/upload", Req) -> upload_endpoint(Req); handle_route(~"/async-db", Req) -> async_db_endpoint(Req); +handle_route(~"/fortunes", Req) -> + fortunes_endpoint(Req); handle_route(~"/ws", Req) -> {{websocket, roadrunner_httparena_ws, undefined}, Req}; handle_route(_, Req) -> @@ -82,6 +85,32 @@ clamp(N, Lo, _Hi) when N < Lo -> Lo; clamp(N, _Lo, Hi) when N > Hi -> Hi; clamp(N, _Lo, _Hi) -> N. +fortunes_endpoint(Req) -> + Rows = + case roadrunner_httparena_db:query(~"SELECT id, message FROM fortune", []) of + {ok, _Cols, R} -> R; + _ -> [] + end, + Runtime = {0, ~"Additional fortune added at request time."}, + Sorted = lists:keysort(2, [Runtime | Rows]), + Body = render_fortunes(Sorted), + {roadrunner_resp:html(200, Body), Req}. + +render_fortunes(Rows) -> + [ + ~"Fortunes", + [render_fortune_row(Id, Msg) || {Id, Msg} <- Rows], + ~"
idmessage
" + ]. + +render_fortune_row(Id, Msg) -> + [~"", integer_to_binary(Id), ~"", html_escape(Msg), ~""]. + +html_escape(Bin) when is_binary(Bin) -> + B1 = binary:replace(Bin, ~"&", ~"&", [global]), + B2 = binary:replace(B1, ~"<", ~"<", [global]), + binary:replace(B2, ~">", ~">", [global]). + baseline(Req) -> A = qs_int(~"a", Req, 0), B = qs_int(~"b", Req, 0), From 379554c013a18ac0efeb95a334574a6d33ce99b5 Mon Sep 17 00:00:00 2001 From: williamthome Date: Sun, 10 May 2026 23:20:48 -0300 Subject: [PATCH 13/22] Add CRUD endpoints with ETS cache for cache-aside profile --- frameworks/roadrunner/README.md | 4 +- frameworks/roadrunner/meta.json | 1 + .../src/roadrunner_httparena_app.erl | 1 + .../src/roadrunner_httparena_crud.erl | 159 ++++++++++++++++++ .../src/roadrunner_httparena_handler.erl | 14 ++ 5 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 frameworks/roadrunner/src/roadrunner_httparena_crud.erl diff --git a/frameworks/roadrunner/README.md b/frameworks/roadrunner/README.md index 48245a591..5b1cb0991 100644 --- a/frameworks/roadrunner/README.md +++ b/frameworks/roadrunner/README.md @@ -4,20 +4,20 @@ ## Profiles -Covered: +Covered (16 of 28 profiles): - `baseline`, `pipelined`, `limited-conn` - `json`, `json-comp`, `json-tls` - `upload` - `async-db`, `api-4`, `api-16` - `static` +- `fortunes`, `crud` - `baseline-h2` - `echo-ws`, `echo-ws-pipeline` Deferred (tracked under [HttpArena coverage gaps](https://github.com/arizona-framework/roadrunner/blob/main/docs/roadmap.md) in the roadrunner roadmap): - `static-h2`: needs `{sendfile, _}` over h2 in roadrunner. -- `fortunes`, `crud`: bench-app endpoints, not roadrunner gaps. - `baseline-h2c`, `json-h2c`: roadrunner is h2-over-TLS-only today; h2c (cleartext h2) is a roadrunner-side gap. - `baseline-h3`, `static-h3`: roadrunner has no HTTP/3 stack yet. - `unary-grpc`, `stream-grpc`, TLS variants: no gRPC stack. diff --git a/frameworks/roadrunner/meta.json b/frameworks/roadrunner/meta.json index a0a7cd8b9..594c174ea 100644 --- a/frameworks/roadrunner/meta.json +++ b/frameworks/roadrunner/meta.json @@ -19,6 +19,7 @@ "api-16", "static", "fortunes", + "crud", "baseline-h2", "echo-ws", "echo-ws-pipeline" diff --git a/frameworks/roadrunner/src/roadrunner_httparena_app.erl b/frameworks/roadrunner/src/roadrunner_httparena_app.erl index 91ccdb63a..115388de3 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_app.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_app.erl @@ -7,6 +7,7 @@ start(_StartType, _StartArgs) -> {ok, SupPid} = roadrunner_httparena_sup:start_link(), ok = roadrunner_httparena_dataset:load(), _ = roadrunner_httparena_db:start_pool(), + ok = roadrunner_httparena_crud:init(), Routes = roadrunner_httparena_handler:routes(), HttpPort = application:get_env(roadrunner_httparena, http_port, 8080), {ok, _} = roadrunner:start_listener(httparena_http, #{ diff --git a/frameworks/roadrunner/src/roadrunner_httparena_crud.erl b/frameworks/roadrunner/src/roadrunner_httparena_crud.erl new file mode 100644 index 000000000..125c0d032 --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena_crud.erl @@ -0,0 +1,159 @@ +-module(roadrunner_httparena_crud). + +-export([init/0]). +-export([list/1, get/1, create/1, update/1]). + +-define(CACHE, httparena_crud_cache). + +-spec init() -> ok. +init() -> + case ets:info(?CACHE, name) of + undefined -> + ets:new(?CACHE, [ + public, + named_table, + {read_concurrency, true}, + {write_concurrency, true} + ]); + _ -> + ok + end, + ok. + +list(Req) -> + Qs = roadrunner_req:parse_qs(Req), + Cat = qs_bin(~"category", Qs, ~"electronics"), + Page = max(1, qs_int(~"page", Qs, 1)), + Limit = clamp(qs_int(~"limit", Qs, 10), 1, 50), + Offset = (Page - 1) * Limit, + ListSql = ~""" + SELECT id, name, category, price, quantity, active, tags, + rating_score, rating_count + FROM items + WHERE category = $1 + ORDER BY id + LIMIT $2 OFFSET $3 + """, + CountSql = ~"SELECT COUNT(*) FROM items WHERE category = $1", + Items = + case roadrunner_httparena_db:query(ListSql, [Cat, Limit, Offset]) of + {ok, _, Rows} -> [row_to_json(R) || R <- Rows]; + _ -> [] + end, + Total = + case roadrunner_httparena_db:query(CountSql, [Cat]) of + {ok, _, [{N}]} -> N; + _ -> 0 + end, + Body = #{~"items" => Items, ~"total" => Total, ~"page" => Page}, + {roadrunner_resp:json(200, Body), Req}. + +get(Req) -> + Id = id_from_path(Req), + case ets:lookup(?CACHE, Id) of + [{Id, Item}] -> + Resp = roadrunner_resp:json(200, Item), + {roadrunner_resp:add_header(Resp, ~"x-cache", ~"HIT"), Req}; + [] -> + case roadrunner_httparena_db:query(read_sql(), [Id]) of + {ok, _, [Row]} -> + Item = row_to_json(Row), + ets:insert(?CACHE, {Id, Item}), + Resp = roadrunner_resp:json(200, Item), + {roadrunner_resp:add_header(Resp, ~"x-cache", ~"MISS"), Req}; + _ -> + {roadrunner_resp:not_found(), Req} + end + end. + +create(Req) -> + {ok, Body, Req2} = roadrunner_req:read_body(Req), + Input = json:decode(Body), + Id = maps:get(~"id", Input), + Sql = ~""" + INSERT INTO items (id, name, category, price, quantity, + active, tags, rating_score, rating_count) + VALUES ($1, $2, $3, $4, $5, true, '[]'::jsonb, 0, 0) + """, + case roadrunner_httparena_db:query(Sql, [ + Id, + maps:get(~"name", Input), + maps:get(~"category", Input), + maps:get(~"price", Input), + maps:get(~"quantity", Input) + ]) of + {ok, 1} -> + ets:delete(?CACHE, Id), + {roadrunner_resp:status(201), Req2}; + _ -> + {roadrunner_resp:internal_error(), Req2} + end. + +update(Req) -> + Id = id_from_path(Req), + {ok, Body, Req2} = roadrunner_req:read_body(Req), + Input = json:decode(Body), + Sql = ~""" + UPDATE items + SET name = $2, category = $3, price = $4, quantity = $5 + WHERE id = $1 + """, + case roadrunner_httparena_db:query(Sql, [ + Id, + maps:get(~"name", Input), + maps:get(~"category", Input), + maps:get(~"price", Input), + maps:get(~"quantity", Input) + ]) of + {ok, 1} -> + ets:delete(?CACHE, Id), + {roadrunner_resp:status(200), Req2}; + {ok, 0} -> + {roadrunner_resp:not_found(), Req2}; + _ -> + {roadrunner_resp:internal_error(), Req2} + end. + +read_sql() -> + ~""" + SELECT id, name, category, price, quantity, active, tags, + rating_score, rating_count + FROM items + WHERE id = $1 + """. + +row_to_json({Id, Name, Cat, Price, Qty, Active, TagsJsonb, RScore, RCount}) -> + #{ + ~"id" => Id, + ~"name" => Name, + ~"category" => Cat, + ~"price" => Price, + ~"quantity" => Qty, + ~"active" => Active, + ~"tags" => json:decode(TagsJsonb), + ~"rating" => #{~"score" => RScore, ~"count" => RCount} + }. + +id_from_path(Req) -> + binary_to_integer(maps:get(~"id", roadrunner_req:bindings(Req))). + +qs_int(Key, Qs, Default) -> + case lists:keyfind(Key, 1, Qs) of + {Key, V} when is_binary(V) -> + case string:to_integer(V) of + {N, _} when is_integer(N) -> N; + _ -> Default + end; + _ -> + Default + end. + +qs_bin(Key, Qs, Default) -> + case lists:keyfind(Key, 1, Qs) of + {Key, V} when is_binary(V) -> V; + _ -> Default + end. + +clamp(N, Lo, _Hi) when N < Lo -> Lo; +clamp(N, _Lo, Hi) when N > Hi -> Hi; +clamp(N, _Lo, _Hi) -> N. diff --git a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl index e28575992..c3b50a9d0 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl @@ -14,6 +14,8 @@ routes() -> {~"/upload", ?MODULE, undefined}, {~"/async-db", ?MODULE, undefined}, {~"/fortunes", ?MODULE, undefined}, + {~"/crud/items", ?MODULE, undefined}, + {~"/crud/items/:id", ?MODULE, undefined}, {~"/ws", ?MODULE, undefined}, {~"/static/*path", roadrunner_static, #{dir => static_dir()}} ]. @@ -42,6 +44,18 @@ handle_route(~"/async-db", Req) -> async_db_endpoint(Req); handle_route(~"/fortunes", Req) -> fortunes_endpoint(Req); +handle_route(~"/crud/items", Req) -> + case roadrunner_req:method(Req) of + ~"GET" -> roadrunner_httparena_crud:list(Req); + ~"POST" -> roadrunner_httparena_crud:create(Req); + _ -> {roadrunner_resp:status(405), Req} + end; +handle_route(<<"/crud/items/", _/binary>>, Req) -> + case roadrunner_req:method(Req) of + ~"GET" -> roadrunner_httparena_crud:get(Req); + ~"PUT" -> roadrunner_httparena_crud:update(Req); + _ -> {roadrunner_resp:status(405), Req} + end; handle_route(~"/ws", Req) -> {{websocket, roadrunner_httparena_ws, undefined}, Req}; handle_route(_, Req) -> From 454535dc485b0a077c8eb72110d9a40d7b1fa8ce Mon Sep 17 00:00:00 2001 From: williamthome Date: Sun, 10 May 2026 23:28:17 -0300 Subject: [PATCH 14/22] Expose TLS and HTTP/2 ports in Dockerfile --- frameworks/roadrunner/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frameworks/roadrunner/Dockerfile b/frameworks/roadrunner/Dockerfile index 8e02542cb..8cc7fedb7 100644 --- a/frameworks/roadrunner/Dockerfile +++ b/frameworks/roadrunner/Dockerfile @@ -10,5 +10,5 @@ RUN rebar3 as prod release FROM alpine:3.23 RUN apk add --no-cache libstdc++ ncurses-libs openssl COPY --from=build /src/_build/prod/rel/roadrunner_httparena /app -EXPOSE 8080 +EXPOSE 8080 8081 8443 CMD ["/app/bin/roadrunner_httparena", "foreground"] From ab3c43e21bbd438633ce603db1d9bcecd77bf117 Mon Sep 17 00:00:00 2001 From: williamthome Date: Mon, 11 May 2026 05:58:04 -0300 Subject: [PATCH 15/22] Extract items row-to-JSON into shared module --- .../roadrunner/src/roadrunner_httparena_crud.erl | 16 ++-------------- .../src/roadrunner_httparena_handler.erl | 14 +------------- .../src/roadrunner_httparena_items.erl | 16 ++++++++++++++++ 3 files changed, 19 insertions(+), 27 deletions(-) create mode 100644 frameworks/roadrunner/src/roadrunner_httparena_items.erl diff --git a/frameworks/roadrunner/src/roadrunner_httparena_crud.erl b/frameworks/roadrunner/src/roadrunner_httparena_crud.erl index 125c0d032..8cbaff9bd 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_crud.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_crud.erl @@ -37,7 +37,7 @@ list(Req) -> CountSql = ~"SELECT COUNT(*) FROM items WHERE category = $1", Items = case roadrunner_httparena_db:query(ListSql, [Cat, Limit, Offset]) of - {ok, _, Rows} -> [row_to_json(R) || R <- Rows]; + {ok, _, Rows} -> [roadrunner_httparena_items:row_to_json(R) || R <- Rows]; _ -> [] end, Total = @@ -57,7 +57,7 @@ get(Req) -> [] -> case roadrunner_httparena_db:query(read_sql(), [Id]) of {ok, _, [Row]} -> - Item = row_to_json(Row), + Item = roadrunner_httparena_items:row_to_json(Row), ets:insert(?CACHE, {Id, Item}), Resp = roadrunner_resp:json(200, Item), {roadrunner_resp:add_header(Resp, ~"x-cache", ~"MISS"), Req}; @@ -122,18 +122,6 @@ read_sql() -> WHERE id = $1 """. -row_to_json({Id, Name, Cat, Price, Qty, Active, TagsJsonb, RScore, RCount}) -> - #{ - ~"id" => Id, - ~"name" => Name, - ~"category" => Cat, - ~"price" => Price, - ~"quantity" => Qty, - ~"active" => Active, - ~"tags" => json:decode(TagsJsonb), - ~"rating" => #{~"score" => RScore, ~"count" => RCount} - }. - id_from_path(Req) -> binary_to_integer(maps:get(~"id", roadrunner_req:bindings(Req))). diff --git a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl index c3b50a9d0..e0a3e1523 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl @@ -77,24 +77,12 @@ async_db_endpoint(Req) -> """, Items = case roadrunner_httparena_db:query(Sql, [Min, Max, Limit]) of - {ok, _Cols, Rows} -> [row_to_json(R) || R <- Rows]; + {ok, _Cols, Rows} -> [roadrunner_httparena_items:row_to_json(R) || R <- Rows]; _ -> [] end, Body = #{~"count" => length(Items), ~"items" => Items}, {roadrunner_resp:json(200, Body), Req}. -row_to_json({Id, Name, Cat, Price, Qty, Active, TagsJsonb, RScore, RCount}) -> - #{ - ~"id" => Id, - ~"name" => Name, - ~"category" => Cat, - ~"price" => Price, - ~"quantity" => Qty, - ~"active" => Active, - ~"tags" => json:decode(TagsJsonb), - ~"rating" => #{~"score" => RScore, ~"count" => RCount} - }. - clamp(N, Lo, _Hi) when N < Lo -> Lo; clamp(N, _Lo, Hi) when N > Hi -> Hi; clamp(N, _Lo, _Hi) -> N. diff --git a/frameworks/roadrunner/src/roadrunner_httparena_items.erl b/frameworks/roadrunner/src/roadrunner_httparena_items.erl new file mode 100644 index 000000000..5a2fc64c5 --- /dev/null +++ b/frameworks/roadrunner/src/roadrunner_httparena_items.erl @@ -0,0 +1,16 @@ +-module(roadrunner_httparena_items). + +-export([row_to_json/1]). + +-spec row_to_json(tuple()) -> map(). +row_to_json({Id, Name, Cat, Price, Qty, Active, TagsJsonb, RScore, RCount}) -> + #{ + ~"id" => Id, + ~"name" => Name, + ~"category" => Cat, + ~"price" => Price, + ~"quantity" => Qty, + ~"active" => Active, + ~"tags" => json:decode(TagsJsonb), + ~"rating" => #{~"score" => RScore, ~"count" => RCount} + }. From 291307a83dcf5613df7f5c267e5cd43c564a2788 Mon Sep 17 00:00:00 2001 From: williamthome Date: Mon, 11 May 2026 15:37:06 -0300 Subject: [PATCH 16/22] Enable h2c profiles --- frameworks/roadrunner/.gitignore | 1 + frameworks/roadrunner/meta.json | 2 ++ frameworks/roadrunner/rebar.config | 2 +- frameworks/roadrunner/rebar.lock | 2 +- frameworks/roadrunner/src/roadrunner_httparena_app.erl | 8 ++++++++ 5 files changed, 13 insertions(+), 2 deletions(-) diff --git a/frameworks/roadrunner/.gitignore b/frameworks/roadrunner/.gitignore index 0fd9ded3a..841533328 100644 --- a/frameworks/roadrunner/.gitignore +++ b/frameworks/roadrunner/.gitignore @@ -1,4 +1,5 @@ _build/ +_checkouts/ .rebar3/ *.beam *.swp diff --git a/frameworks/roadrunner/meta.json b/frameworks/roadrunner/meta.json index 594c174ea..b1df82255 100644 --- a/frameworks/roadrunner/meta.json +++ b/frameworks/roadrunner/meta.json @@ -21,6 +21,8 @@ "fortunes", "crud", "baseline-h2", + "baseline-h2c", + "json-h2c", "echo-ws", "echo-ws-pipeline" ], diff --git a/frameworks/roadrunner/rebar.config b/frameworks/roadrunner/rebar.config index b46404baa..aceec399b 100644 --- a/frameworks/roadrunner/rebar.config +++ b/frameworks/roadrunner/rebar.config @@ -2,7 +2,7 @@ {deps, [ {roadrunner, {git, "https://github.com/arizona-framework/roadrunner.git", - {ref, "5522bddf8916faea6c9f74be2794e5b8d7113c62"}}}, + {ref, "8a29f30b91552bcfe7a6d56d3562700c5bd6cd6b"}}}, {epgsql, "4.8.0"}, {pooler, "1.6.0"} ]}. diff --git a/frameworks/roadrunner/rebar.lock b/frameworks/roadrunner/rebar.lock index 0dcfc84b1..61554e757 100644 --- a/frameworks/roadrunner/rebar.lock +++ b/frameworks/roadrunner/rebar.lock @@ -3,7 +3,7 @@ {<<"pooler">>,{pkg,<<"pooler">>,<<"1.6.0">>},0}, {<<"roadrunner">>, {git,"https://github.com/arizona-framework/roadrunner.git", - {ref,"5522bddf8916faea6c9f74be2794e5b8d7113c62"}}, + {ref,"8a29f30b91552bcfe7a6d56d3562700c5bd6cd6b"}}, 0}, {<<"telemetry">>, {git,"https://github.com/beam-telemetry/telemetry.git", diff --git a/frameworks/roadrunner/src/roadrunner_httparena_app.erl b/frameworks/roadrunner/src/roadrunner_httparena_app.erl index 115388de3..9563bab0c 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_app.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_app.erl @@ -17,6 +17,14 @@ start(_StartType, _StartArgs) -> %% 25 MB headroom for the upload profile (validator goes up to 20 MB). max_content_length => 26214400 }), + H2cPort = application:get_env(roadrunner_httparena, h2c_port, 8082), + {ok, _} = roadrunner:start_listener(httparena_h2c, #{ + port => H2cPort, + routes => Routes, + middlewares => [roadrunner_compress], + max_content_length => 26214400, + h2c => enabled + }), case tls_opts() of {ok, TlsOpts} -> TlsPort = application:get_env(roadrunner_httparena, tls_port, 8081), From 0dd4c787d6b7e5673655c6f128b09308cc9f2582 Mon Sep 17 00:00:00 2001 From: williamthome Date: Mon, 11 May 2026 16:41:39 -0300 Subject: [PATCH 17/22] Bump roadrunner SHA and subscribe to static-h2 --- frameworks/roadrunner/meta.json | 1 + frameworks/roadrunner/rebar.config | 2 +- frameworks/roadrunner/rebar.lock | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frameworks/roadrunner/meta.json b/frameworks/roadrunner/meta.json index b1df82255..265bb108d 100644 --- a/frameworks/roadrunner/meta.json +++ b/frameworks/roadrunner/meta.json @@ -21,6 +21,7 @@ "fortunes", "crud", "baseline-h2", + "static-h2", "baseline-h2c", "json-h2c", "echo-ws", diff --git a/frameworks/roadrunner/rebar.config b/frameworks/roadrunner/rebar.config index aceec399b..4d7ed2b2c 100644 --- a/frameworks/roadrunner/rebar.config +++ b/frameworks/roadrunner/rebar.config @@ -2,7 +2,7 @@ {deps, [ {roadrunner, {git, "https://github.com/arizona-framework/roadrunner.git", - {ref, "8a29f30b91552bcfe7a6d56d3562700c5bd6cd6b"}}}, + {ref, "9892893c5482f1ad2b4cbbaeb249ca2a92aeda7a"}}}, {epgsql, "4.8.0"}, {pooler, "1.6.0"} ]}. diff --git a/frameworks/roadrunner/rebar.lock b/frameworks/roadrunner/rebar.lock index 61554e757..370b2d2c7 100644 --- a/frameworks/roadrunner/rebar.lock +++ b/frameworks/roadrunner/rebar.lock @@ -3,7 +3,7 @@ {<<"pooler">>,{pkg,<<"pooler">>,<<"1.6.0">>},0}, {<<"roadrunner">>, {git,"https://github.com/arizona-framework/roadrunner.git", - {ref,"8a29f30b91552bcfe7a6d56d3562700c5bd6cd6b"}}, + {ref,"9892893c5482f1ad2b4cbbaeb249ca2a92aeda7a"}}, 0}, {<<"telemetry">>, {git,"https://github.com/beam-telemetry/telemetry.git", From 06f6a64a11feaeafef24bce6dd4a43c43b220750 Mon Sep 17 00:00:00 2001 From: williamthome Date: Mon, 11 May 2026 17:28:42 -0300 Subject: [PATCH 18/22] Capture benchmark-lite snapshot under bench-results.md --- frameworks/roadrunner/bench-results.md | 96 ++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 frameworks/roadrunner/bench-results.md diff --git a/frameworks/roadrunner/bench-results.md b/frameworks/roadrunner/bench-results.md new file mode 100644 index 000000000..8bccff1ba --- /dev/null +++ b/frameworks/roadrunner/bench-results.md @@ -0,0 +1,96 @@ +# Local benchmark-lite snapshot (working artifact, removed before merge) + +This file is a working artifact captured during the upload-streaming +improvement work. It will be removed in a dedicated cleanup commit +before the PR is opened so the merged contribution doesn't include +it. + +## Setup + +- Harness: `bash scripts/benchmark-lite.sh roadrunner` from the + HttpArena repo root. +- Hardware: 24-core laptop, `nproc / 2 = 12` threads, no CPU + pinning, shared with the host. +- Profiles run: the 11 that lite mode covers. Lite skips api-4/16, + json-tls, fortunes, crud, h2c profiles, echo-ws-pipeline, and + h3 / grpc. +- Roadrunner SHA pinned at commit time: + `9892893c5482f1ad2b4cbbaeb249ca2a92aeda7a` (tip of + `feat/http-arena`). + +## Before — baseline body buffering (default `auto`) + +| Profile | Throughput | CPU | Mem | Tool | +|---|---:|---:|---:|---| +| baseline | 432K req/s | 1581% | 257 MiB | gcannon | +| pipelined | **897K req/s** | 1561% | 161 MiB | gcannon | +| limited-conn | 320K req/s | 1733% | 470 MiB | gcannon | +| json | 149K req/s | 1870% | 375 MiB | gcannon | +| json-comp | 57K req/s | 2005% | 616 MiB | gcannon | +| upload | 749 req/s | 1187% | **4.1 GiB** | gcannon | +| static (h1) | 36K req/s | 1155% | 148 MiB | wrk | +| async-db | 37K req/s | 1242% | 529 MiB | gcannon | +| baseline-h2 | 337K req/s | 1736% | 380 MiB | h2load | +| static-h2 | 19K req/s | 1140% | 1.6 GiB | h2load | +| echo-ws | 574K req/s | 1560% | 424 MiB | gcannon | + +The outlier is `upload` at 4.1 GiB peak. Every other profile sits +at 150-700 MiB. The validator's upload payloads peak at 20 MB and +the handler only needs the byte count, so the in-flight memory is +purely from the default `body_buffering => auto` mode buffering the +full request body in conn-process binaries before dispatching the +handler. + +## Why only upload gets a code change this round + +For every other profile (baseline, json, json-comp, static, async-db, +baseline-h2, echo-ws) we do not know the bottleneck. Could be HPACK +encoding, JSON encoding, route dispatch, header parsing, atomics +contention, persistent_term lookups, body framing, etc. Guessing +without `fprof` / `eprof` / `perf record` would just optimize the +cold path. + +Roadrunner now carries a roadmap entry (`docs/roadmap.md`, under +`## Other`: "HttpArena-shape bench scenarios for profile-driven +optimization") that tracks adding HttpArena-shape scenarios to +`scripts/wrk2_bench.sh` and `scripts/bench.escript` so profiling +can run against the actual workload shapes that determine ranking. +That's a multi-session arc, not this commit. + +## Leaderboard comparison + +Pulled from `site/data/-.json` files in this repo +(populated from the official benchmark runs on 64-core dedicated +hardware, cores 0-31 and 64-95 pinned, 64 threads). + +Roadrunner would be the **first BEAM framework on the +leaderboard** — no Erlang / Elixir / Gleam entry exists across any +profile. + +Raw rank uses the unscaled lite numbers (apples-to-oranges; lite +mode runs ~5x fewer threads on a laptop instead of dedicated 64-core +hardware). The 5x-scaled estimate is a rough upper bound; reality +is usually 2-4x because scaling past 12 threads is non-linear. + +| Profile | Lite (mine) | Raw rank | 5x est. | Est. rank | Near (5x scale) | +|---|---:|---:|---:|---:|---| +| baseline | 432K | #41/59 | 2.2M | #14/59 | between actix (Rust) and h2o (C) | +| pipelined | 897K | #35/57 | 4.5M | #19/57 | between swerver (Zig) and quarkus (Java) | +| json | 149K | #35/45 | 745K | #10/45 | near workerman (PHP) / actix (Rust) | +| json-comp | 57K | #33/40 | 287K | #16/40 | mid-pack | +| upload | 749 | #38/44 | 3.7K | **#1/44** | top spot (3.2K humming-bird, 3.1K actix) | +| static (h1) | 36K | #45/51 | 182K | #32/51 | bottom-half (wrk-driven, app-bound) | +| async-db | 37K | #35/42 | 187K | #7/42 | top-10 candidate (Swoole / aspnet-aot tier) | +| baseline-h2 | 337K | #19/21 | 1.7M | #15/21 | mid-pack of h2 entries | +| echo-ws | 574K | #16/16 | 2.9M | #7/16 | near lute / dogrider (~3M) | + +Honest framing: **mid-pack on CPU-bound profiles, top-10 candidate +on work-bound profiles (async-db, json-comp), top-tier on upload +after the streaming fix**. Behind the C / Rust tier (h2o, ringzero, +rust-epoll, actix, hyper); comparable to high-end Java / C# +frameworks; well ahead of unoptimized Node / Python. + +## After — upload streaming (`body_buffering => manual` + `read_body_chunked/1`) + +_Filled in once the upload streaming commit lands and a fresh +`benchmark-lite.sh roadrunner upload` is captured._ From 2dd3e568ee3be921f2efa251e2e2621cd9f793c8 Mon Sep 17 00:00:00 2001 From: williamthome Date: Mon, 11 May 2026 20:24:44 -0300 Subject: [PATCH 19/22] Stream 20 MB upload via body_buffering=manual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-buffering path can't handle 128 concurrent 20 MB POSTs regardless of how the conn accumulates bytes (peak RSS >8 GiB, 0 req/s under the validator's load). All four listeners now declare `body_buffering => manual`; the upload handler drains the body in 64 KB chunks via `roadrunner_req:read_body/2 #{length => 65536}`. benchmark-lite roadrunner upload (128c, 5s × 3): | stage | req/s | RSS | |---|---:|---:| | auto (pre-fix) | 749 | 4.1 GiB | | auto (post roadrunner fixes) | 0 | 8.4 GiB | | manual stream | 1566 | 399 MiB | Roadrunner SHA bumped to a8596b786eff for the iodata body field. --- frameworks/roadrunner/bench-results.md | 28 ++++++++++++++++--- frameworks/roadrunner/rebar.config | 2 +- .../src/roadrunner_httparena_app.erl | 17 ++++++++--- .../src/roadrunner_httparena_handler.erl | 18 ++++++++++-- 4 files changed, 54 insertions(+), 11 deletions(-) diff --git a/frameworks/roadrunner/bench-results.md b/frameworks/roadrunner/bench-results.md index 8bccff1ba..a6bc9836a 100644 --- a/frameworks/roadrunner/bench-results.md +++ b/frameworks/roadrunner/bench-results.md @@ -90,7 +90,27 @@ after the streaming fix**. Behind the C / Rust tier (h2o, ringzero, rust-epoll, actix, hyper); comparable to high-end Java / C# frameworks; well ahead of unoptimized Node / Python. -## After — upload streaming (`body_buffering => manual` + `read_body_chunked/1`) - -_Filled in once the upload streaming commit lands and a fresh -`benchmark-lite.sh roadrunner upload` is captured._ +## After — upload streaming (`body_buffering => manual` + `read_body/2 #{length}`) + +Captured via `bash scripts/benchmark-lite.sh roadrunner upload`, same +hardware, after the bench-app handler streams via +`roadrunner_req:read_body(Req, #{length => 65536})` and listeners +declare `body_buffering => manual`. Roadrunner SHA pinned at +`a8596b786effa0ad8706548ba0a5a3de0ef6cda3`. + +| Run | Throughput | CPU | Mem | Errors | +|---|---:|---:|---:|---:| +| 1/3 | 1.4 K | 982 % | 313 MiB | 0 | +| 2/3 | 1.53 K | 1088 % | 367 MiB | 0 | +| 3/3 | 1.57 K | 1048 % | 399 MiB | 0 | + +**Delta vs auto-mode baseline (749 r/s, 4.1 GiB):** 2.1× throughput, +**10× less RSS**, status codes 100 % 2xx. The roadrunner-side +upload-path improvements (`b507415` quadratic-concat fix, +`a8596b786eff` iodata body) make `body_buffering => auto` viable for +low-concurrency cases, but at HttpArena's 128c × 20 MB workload only +the manual streaming path bounds memory. + +Leaderboard impact: at the 5× scale extrapolation, ~7.8 K req/s +would place roadrunner **top-tier on `upload`**, above the prior #1 +candidates (humming-bird 3.2 K, actix 3.1 K). diff --git a/frameworks/roadrunner/rebar.config b/frameworks/roadrunner/rebar.config index 4d7ed2b2c..374b07762 100644 --- a/frameworks/roadrunner/rebar.config +++ b/frameworks/roadrunner/rebar.config @@ -2,7 +2,7 @@ {deps, [ {roadrunner, {git, "https://github.com/arizona-framework/roadrunner.git", - {ref, "9892893c5482f1ad2b4cbbaeb249ca2a92aeda7a"}}}, + {ref, "a8596b786effa0ad8706548ba0a5a3de0ef6cda3"}}}, {epgsql, "4.8.0"}, {pooler, "1.6.0"} ]}. diff --git a/frameworks/roadrunner/src/roadrunner_httparena_app.erl b/frameworks/roadrunner/src/roadrunner_httparena_app.erl index 9563bab0c..e2a0f0d51 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_app.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_app.erl @@ -15,7 +15,13 @@ start(_StartType, _StartArgs) -> routes => Routes, middlewares => [roadrunner_compress], %% 25 MB headroom for the upload profile (validator goes up to 20 MB). - max_content_length => 26214400 + max_content_length => 26214400, + %% Manual body buffering: handlers read the body themselves via + %% `roadrunner_req:read_body[_chunked]/1`. Lets the upload handler + %% stream chunks instead of buffering the entire 20 MB body in + %% the conn process before dispatch. Auto-mode handlers + %% (`baseline11` POST) still work transparently via `read_body/1`. + body_buffering => manual }), H2cPort = application:get_env(roadrunner_httparena, h2c_port, 8082), {ok, _} = roadrunner:start_listener(httparena_h2c, #{ @@ -23,7 +29,8 @@ start(_StartType, _StartArgs) -> routes => Routes, middlewares => [roadrunner_compress], max_content_length => 26214400, - h2c => enabled + h2c => enabled, + body_buffering => manual }), case tls_opts() of {ok, TlsOpts} -> @@ -33,7 +40,8 @@ start(_StartType, _StartArgs) -> routes => Routes, middlewares => [roadrunner_compress], max_content_length => 26214400, - tls => TlsOpts + tls => TlsOpts, + body_buffering => manual }), H2Port = application:get_env(roadrunner_httparena, h2_port, 8443), H2TlsOpts = [{alpn_preferred_protocols, [~"h2", ~"http/1.1"]} | TlsOpts], @@ -42,7 +50,8 @@ start(_StartType, _StartArgs) -> routes => Routes, middlewares => [roadrunner_compress], max_content_length => 26214400, - tls => H2TlsOpts + tls => H2TlsOpts, + body_buffering => manual }); skip -> ok diff --git a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl index e0a3e1523..b650667a3 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_handler.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_handler.erl @@ -62,8 +62,22 @@ handle_route(_, Req) -> {roadrunner_resp:not_found(), Req}. upload_endpoint(Req) -> - {ok, Body, Req2} = roadrunner_req:read_body(Req), - {roadrunner_resp:text(200, integer_to_binary(byte_size(Body))), Req2}. + {Count, Req2} = consume_body(Req, 0), + {roadrunner_resp:text(200, integer_to_binary(Count)), Req2}. + +%% Stream the request body in 64 KB chunks, discarding each chunk +%% after counting its bytes via `iolist_size/1` (the auto-buffered body +%% is `iodata()`, not `binary()`). With `body_buffering => manual` on +%% the listener, `read_body/2 #{length => 65536}` returns one chunk at +%% a time so peak memory stays bounded even for the 20 MB upload +%% validator case. +consume_body(Req, Acc) -> + case roadrunner_req:read_body(Req, #{length => 65536}) of + {ok, Bytes, Req2} -> + {Acc + iolist_size(Bytes), Req2}; + {more, Bytes, Req2} -> + consume_body(Req2, Acc + iolist_size(Bytes)) + end. async_db_endpoint(Req) -> Min = qs_int(~"min", Req, 10), From 9a670ddf80d36e7daf8691cb9b3cf2063fc29ec4 Mon Sep 17 00:00:00 2001 From: williamthome Date: Sun, 17 May 2026 14:20:00 -0300 Subject: [PATCH 20/22] Pin roadrunner to hex 0.1.0 Listener opts reshaped in v0.1.0: h2c => enabled becomes protocols => [http2] on plain TCP; TLS h2 uses protocols => [http2, http1] with auto-derived alpn_preferred_protocols. OTP 29 GA dropped the rebar3 TLS-1.2 workaround needed for RC3 + Fastly, so retire config/rebar3_ssl.config + ERL_FLAGS and bump the Dockerfile base to erlang:29.0-alpine. Add 8082 to EXPOSE. README: move baseline-h2c, json-h2c, static-h2 from deferred to covered (19/28) to match meta.json and the live listeners. --- frameworks/roadrunner/Dockerfile | 5 ++--- frameworks/roadrunner/README.md | 8 +++----- frameworks/roadrunner/config/rebar3_ssl.config | 11 ----------- frameworks/roadrunner/rebar.config | 5 +++-- frameworks/roadrunner/rebar.lock | 18 ++++++++---------- .../src/roadrunner_httparena_app.erl | 11 ++++++++--- 6 files changed, 24 insertions(+), 34 deletions(-) delete mode 100644 frameworks/roadrunner/config/rebar3_ssl.config diff --git a/frameworks/roadrunner/Dockerfile b/frameworks/roadrunner/Dockerfile index 8cc7fedb7..6d348e918 100644 --- a/frameworks/roadrunner/Dockerfile +++ b/frameworks/roadrunner/Dockerfile @@ -1,14 +1,13 @@ -FROM erlang:29.0.0.0-rc3-alpine AS build +FROM erlang:29.0-alpine AS build RUN apk add --no-cache ca-certificates git WORKDIR /src COPY rebar.config ./ COPY config ./config COPY src ./src -ENV ERL_FLAGS="-config /src/config/rebar3_ssl" RUN rebar3 as prod release FROM alpine:3.23 RUN apk add --no-cache libstdc++ ncurses-libs openssl COPY --from=build /src/_build/prod/rel/roadrunner_httparena /app -EXPOSE 8080 8081 8443 +EXPOSE 8080 8081 8082 8443 CMD ["/app/bin/roadrunner_httparena", "foreground"] diff --git a/frameworks/roadrunner/README.md b/frameworks/roadrunner/README.md index 5b1cb0991..c1f5efbc1 100644 --- a/frameworks/roadrunner/README.md +++ b/frameworks/roadrunner/README.md @@ -4,21 +4,19 @@ ## Profiles -Covered (16 of 28 profiles): +Covered (19 of 28 profiles): - `baseline`, `pipelined`, `limited-conn` - `json`, `json-comp`, `json-tls` - `upload` - `async-db`, `api-4`, `api-16` -- `static` +- `static`, `static-h2` - `fortunes`, `crud` -- `baseline-h2` +- `baseline-h2`, `baseline-h2c`, `json-h2c` - `echo-ws`, `echo-ws-pipeline` Deferred (tracked under [HttpArena coverage gaps](https://github.com/arizona-framework/roadrunner/blob/main/docs/roadmap.md) in the roadrunner roadmap): -- `static-h2`: needs `{sendfile, _}` over h2 in roadrunner. -- `baseline-h2c`, `json-h2c`: roadrunner is h2-over-TLS-only today; h2c (cleartext h2) is a roadrunner-side gap. - `baseline-h3`, `static-h3`: roadrunner has no HTTP/3 stack yet. - `unary-grpc`, `stream-grpc`, TLS variants: no gRPC stack. - `gateway-64`, `gateway-h3`, `production-stack`: reverse-proxy multi-container scenarios; out of scope for the single-framework entry. diff --git a/frameworks/roadrunner/config/rebar3_ssl.config b/frameworks/roadrunner/config/rebar3_ssl.config deleted file mode 100644 index cf2a44fc9..000000000 --- a/frameworks/roadrunner/config/rebar3_ssl.config +++ /dev/null @@ -1,11 +0,0 @@ -%% rebar3 BEAM TLS workaround for OTP 29 RC3 + Fastly hex.pm fingerprint -%% filter. Loaded by the Dockerfile build stage via -%% ERL_FLAGS="-config /src/config/rebar3_ssl" so it only affects rebar3 -%% during dep fetch, not the application's own ssl listener. -[ - {ssl, [ - {cacertfile, "/etc/ssl/certs/ca-certificates.crt"}, - {middlebox_comp_mode, false}, - {versions, ['tlsv1.2']} - ]} -]. diff --git a/frameworks/roadrunner/rebar.config b/frameworks/roadrunner/rebar.config index 374b07762..485bb50ff 100644 --- a/frameworks/roadrunner/rebar.config +++ b/frameworks/roadrunner/rebar.config @@ -1,8 +1,9 @@ {erl_opts, [debug_info]}. +{minimum_otp_vsn, "29"}. + {deps, [ - {roadrunner, {git, "https://github.com/arizona-framework/roadrunner.git", - {ref, "a8596b786effa0ad8706548ba0a5a3de0ef6cda3"}}}, + {roadrunner, "0.1.0"}, {epgsql, "4.8.0"}, {pooler, "1.6.0"} ]}. diff --git a/frameworks/roadrunner/rebar.lock b/frameworks/roadrunner/rebar.lock index 370b2d2c7..a77186a27 100644 --- a/frameworks/roadrunner/rebar.lock +++ b/frameworks/roadrunner/rebar.lock @@ -1,19 +1,17 @@ {"1.2.0", [{<<"epgsql">>,{pkg,<<"epgsql">>,<<"4.8.0">>},0}, {<<"pooler">>,{pkg,<<"pooler">>,<<"1.6.0">>},0}, - {<<"roadrunner">>, - {git,"https://github.com/arizona-framework/roadrunner.git", - {ref,"9892893c5482f1ad2b4cbbaeb249ca2a92aeda7a"}}, - 0}, - {<<"telemetry">>, - {git,"https://github.com/beam-telemetry/telemetry.git", - {ref,"11462db509623be85c7acf3f15d0579d0d3f4a79"}}, - 1}]}. + {<<"roadrunner">>,{pkg,<<"roadrunner">>,<<"0.1.0">>},0}, + {<<"telemetry">>,{pkg,<<"telemetry">>,<<"1.4.2">>},1}]}. [ {pkg_hash,[ {<<"epgsql">>, <<"C491B141B8C37BCE7B67F2079F168F339BB374A7CF9A286CB3B40AD1CD3FABE5">>}, - {<<"pooler">>, <<"F4F33C94AB3AB82565A2E31CEA9EFE4149A160651F3707A0A2669BC54AAF81C8">>}]}, + {<<"pooler">>, <<"F4F33C94AB3AB82565A2E31CEA9EFE4149A160651F3707A0A2669BC54AAF81C8">>}, + {<<"roadrunner">>, <<"ECC4CA6CFE2ED65661D57DE8FF53986850F62F882AAF76829150F8ED00954249">>}, + {<<"telemetry">>, <<"A0CB522801DFFB1C49FE6E30561BADFFC7B6D0E180DB1300DF759FAA22062855">>}]}, {pkg_hash_ext,[ {<<"epgsql">>, <<"00E550006A62FB439FC7E879419443C889C34605E9B9DC406029D8BAA1AD79D8">>}, - {<<"pooler">>, <<"748C988FD2928DE9577C882A49621863CAB57809E3E1A88A14C9D3B55C6AB877">>}]} + {<<"pooler">>, <<"748C988FD2928DE9577C882A49621863CAB57809E3E1A88A14C9D3B55C6AB877">>}, + {<<"roadrunner">>, <<"1F393EBB706D61427F2C472A362030D45B0E85B59A680E6180154116C8BE4D6A">>}, + {<<"telemetry">>, <<"928F6495066506077862C0D1646609EED891A4326BEE3126BA54B60AF61FEBB1">>}]} ]. diff --git a/frameworks/roadrunner/src/roadrunner_httparena_app.erl b/frameworks/roadrunner/src/roadrunner_httparena_app.erl index e2a0f0d51..64b1c6acd 100644 --- a/frameworks/roadrunner/src/roadrunner_httparena_app.erl +++ b/frameworks/roadrunner/src/roadrunner_httparena_app.erl @@ -29,7 +29,10 @@ start(_StartType, _StartArgs) -> routes => Routes, middlewares => [roadrunner_compress], max_content_length => 26214400, - h2c => enabled, + %% h2c prior-knowledge: `[http2]` on a plain-TCP listener + %% serves h2 directly (client sends the h2 preface, no + %% `Upgrade: h2c` negotiation). + protocols => [http2], body_buffering => manual }), case tls_opts() of @@ -44,13 +47,15 @@ start(_StartType, _StartArgs) -> body_buffering => manual }), H2Port = application:get_env(roadrunner_httparena, h2_port, 8443), - H2TlsOpts = [{alpn_preferred_protocols, [~"h2", ~"http/1.1"]} | TlsOpts], {ok, _} = roadrunner:start_listener(httparena_h2, #{ port => H2Port, routes => Routes, middlewares => [roadrunner_compress], max_content_length => 26214400, - tls => H2TlsOpts, + tls => TlsOpts, + %% Listener derives `alpn_preferred_protocols` from + %% this list — `h2` preferred, fall back to `http/1.1`. + protocols => [http2, http1], body_buffering => manual }); skip -> From cd280cb0d98ca6b2edc3975cb3fb407f80e3fbd0 Mon Sep 17 00:00:00 2001 From: williamthome Date: Sun, 17 May 2026 14:26:39 -0300 Subject: [PATCH 21/22] Drop bench-results.md working artifact The file's own header marked it as a local snapshot to be removed before opening the PR; clearing it out so the merged contribution doesn't ship a stale benchmark capture. --- frameworks/roadrunner/bench-results.md | 116 ------------------------- 1 file changed, 116 deletions(-) delete mode 100644 frameworks/roadrunner/bench-results.md diff --git a/frameworks/roadrunner/bench-results.md b/frameworks/roadrunner/bench-results.md deleted file mode 100644 index a6bc9836a..000000000 --- a/frameworks/roadrunner/bench-results.md +++ /dev/null @@ -1,116 +0,0 @@ -# Local benchmark-lite snapshot (working artifact, removed before merge) - -This file is a working artifact captured during the upload-streaming -improvement work. It will be removed in a dedicated cleanup commit -before the PR is opened so the merged contribution doesn't include -it. - -## Setup - -- Harness: `bash scripts/benchmark-lite.sh roadrunner` from the - HttpArena repo root. -- Hardware: 24-core laptop, `nproc / 2 = 12` threads, no CPU - pinning, shared with the host. -- Profiles run: the 11 that lite mode covers. Lite skips api-4/16, - json-tls, fortunes, crud, h2c profiles, echo-ws-pipeline, and - h3 / grpc. -- Roadrunner SHA pinned at commit time: - `9892893c5482f1ad2b4cbbaeb249ca2a92aeda7a` (tip of - `feat/http-arena`). - -## Before — baseline body buffering (default `auto`) - -| Profile | Throughput | CPU | Mem | Tool | -|---|---:|---:|---:|---| -| baseline | 432K req/s | 1581% | 257 MiB | gcannon | -| pipelined | **897K req/s** | 1561% | 161 MiB | gcannon | -| limited-conn | 320K req/s | 1733% | 470 MiB | gcannon | -| json | 149K req/s | 1870% | 375 MiB | gcannon | -| json-comp | 57K req/s | 2005% | 616 MiB | gcannon | -| upload | 749 req/s | 1187% | **4.1 GiB** | gcannon | -| static (h1) | 36K req/s | 1155% | 148 MiB | wrk | -| async-db | 37K req/s | 1242% | 529 MiB | gcannon | -| baseline-h2 | 337K req/s | 1736% | 380 MiB | h2load | -| static-h2 | 19K req/s | 1140% | 1.6 GiB | h2load | -| echo-ws | 574K req/s | 1560% | 424 MiB | gcannon | - -The outlier is `upload` at 4.1 GiB peak. Every other profile sits -at 150-700 MiB. The validator's upload payloads peak at 20 MB and -the handler only needs the byte count, so the in-flight memory is -purely from the default `body_buffering => auto` mode buffering the -full request body in conn-process binaries before dispatching the -handler. - -## Why only upload gets a code change this round - -For every other profile (baseline, json, json-comp, static, async-db, -baseline-h2, echo-ws) we do not know the bottleneck. Could be HPACK -encoding, JSON encoding, route dispatch, header parsing, atomics -contention, persistent_term lookups, body framing, etc. Guessing -without `fprof` / `eprof` / `perf record` would just optimize the -cold path. - -Roadrunner now carries a roadmap entry (`docs/roadmap.md`, under -`## Other`: "HttpArena-shape bench scenarios for profile-driven -optimization") that tracks adding HttpArena-shape scenarios to -`scripts/wrk2_bench.sh` and `scripts/bench.escript` so profiling -can run against the actual workload shapes that determine ranking. -That's a multi-session arc, not this commit. - -## Leaderboard comparison - -Pulled from `site/data/-.json` files in this repo -(populated from the official benchmark runs on 64-core dedicated -hardware, cores 0-31 and 64-95 pinned, 64 threads). - -Roadrunner would be the **first BEAM framework on the -leaderboard** — no Erlang / Elixir / Gleam entry exists across any -profile. - -Raw rank uses the unscaled lite numbers (apples-to-oranges; lite -mode runs ~5x fewer threads on a laptop instead of dedicated 64-core -hardware). The 5x-scaled estimate is a rough upper bound; reality -is usually 2-4x because scaling past 12 threads is non-linear. - -| Profile | Lite (mine) | Raw rank | 5x est. | Est. rank | Near (5x scale) | -|---|---:|---:|---:|---:|---| -| baseline | 432K | #41/59 | 2.2M | #14/59 | between actix (Rust) and h2o (C) | -| pipelined | 897K | #35/57 | 4.5M | #19/57 | between swerver (Zig) and quarkus (Java) | -| json | 149K | #35/45 | 745K | #10/45 | near workerman (PHP) / actix (Rust) | -| json-comp | 57K | #33/40 | 287K | #16/40 | mid-pack | -| upload | 749 | #38/44 | 3.7K | **#1/44** | top spot (3.2K humming-bird, 3.1K actix) | -| static (h1) | 36K | #45/51 | 182K | #32/51 | bottom-half (wrk-driven, app-bound) | -| async-db | 37K | #35/42 | 187K | #7/42 | top-10 candidate (Swoole / aspnet-aot tier) | -| baseline-h2 | 337K | #19/21 | 1.7M | #15/21 | mid-pack of h2 entries | -| echo-ws | 574K | #16/16 | 2.9M | #7/16 | near lute / dogrider (~3M) | - -Honest framing: **mid-pack on CPU-bound profiles, top-10 candidate -on work-bound profiles (async-db, json-comp), top-tier on upload -after the streaming fix**. Behind the C / Rust tier (h2o, ringzero, -rust-epoll, actix, hyper); comparable to high-end Java / C# -frameworks; well ahead of unoptimized Node / Python. - -## After — upload streaming (`body_buffering => manual` + `read_body/2 #{length}`) - -Captured via `bash scripts/benchmark-lite.sh roadrunner upload`, same -hardware, after the bench-app handler streams via -`roadrunner_req:read_body(Req, #{length => 65536})` and listeners -declare `body_buffering => manual`. Roadrunner SHA pinned at -`a8596b786effa0ad8706548ba0a5a3de0ef6cda3`. - -| Run | Throughput | CPU | Mem | Errors | -|---|---:|---:|---:|---:| -| 1/3 | 1.4 K | 982 % | 313 MiB | 0 | -| 2/3 | 1.53 K | 1088 % | 367 MiB | 0 | -| 3/3 | 1.57 K | 1048 % | 399 MiB | 0 | - -**Delta vs auto-mode baseline (749 r/s, 4.1 GiB):** 2.1× throughput, -**10× less RSS**, status codes 100 % 2xx. The roadrunner-side -upload-path improvements (`b507415` quadratic-concat fix, -`a8596b786eff` iodata body) make `body_buffering => auto` viable for -low-concurrency cases, but at HttpArena's 128c × 20 MB workload only -the manual streaming path bounds memory. - -Leaderboard impact: at the 5× scale extrapolation, ~7.8 K req/s -would place roadrunner **top-tier on `upload`**, above the prior #1 -candidates (humming-bird 3.2 K, actix 3.1 K). From 3ea3a091ed28ba4feef56e93619c4311a60ea7ff Mon Sep 17 00:00:00 2001 From: williamthome Date: Sun, 17 May 2026 14:26:44 -0300 Subject: [PATCH 22/22] Register roadrunner in site/data/frameworks.json The branch added the framework dir and meta.json but never wired the site-level registry, so the entry wouldn't render in the UI even after benchmark CI populates the per-profile result files. --- site/data/frameworks.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/site/data/frameworks.json b/site/data/frameworks.json index c4ccad95a..080d44277 100644 --- a/site/data/frameworks.json +++ b/site/data/frameworks.json @@ -441,6 +441,13 @@ "type": "engine", "engine": "io_uring" }, + "roadrunner": { + "dir": "roadrunner", + "description": "Pure-Erlang HTTP/1.1, HTTP/2, and WebSocket server. Beep beep.", + "repo": "https://github.com/arizona-framework/roadrunner", + "type": "production", + "engine": "roadrunner" + }, "robyn": { "dir": "robyn", "description": "Robyn is a High-Performance Web Framework with a Rust runtime",