Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions frameworks/phoenix/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
FROM elixir:1.17-otp-27-alpine AS build
RUN apk add --no-cache gcc musl-dev make
WORKDIR /app
ENV MIX_ENV=prod

# Install hex + rebar
RUN mix local.hex --force && mix local.rebar --force

# Get deps
COPY mix.exs ./
RUN mix deps.get --only prod
RUN mix deps.compile

# Compile app
COPY config ./config
COPY lib ./lib
RUN mix compile
RUN mix release

FROM erlang:27-alpine
RUN apk add --no-cache libstdc++
WORKDIR /app
COPY --from=build /app/_build/prod/rel/httparena_phoenix /app
EXPOSE 8080
CMD ["/app/bin/httparena_phoenix", "start"]
16 changes: 16 additions & 0 deletions frameworks/phoenix/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Phoenix — Elixir Web Framework

[Phoenix](https://github.com/phoenixframework/phoenix) is the most popular Elixir web framework, built on the BEAM VM (Erlang's runtime). It leverages OTP's lightweight processes for massive concurrency and fault tolerance.

## Stack

- **Phoenix 1.7** on Cowboy HTTP server
- **BEAM VM** (Erlang/OTP 27) — preemptive scheduling, millions of lightweight processes
- **Jason** for JSON encoding/decoding
- **Exqlite** for SQLite access
- Pre-computed JSON + gzip caches via `:persistent_term` (global read-optimized storage)
- Mix release for production deployment

## Why Phoenix?

Phoenix is THE Elixir web framework — ~23k GitHub stars, used in production by companies like Discord (originally), Bleacher Report, PepsiCo. The BEAM VM's concurrency model (lightweight processes with preemptive scheduling) is fundamentally different from thread-based or async/await approaches. Interesting to see how it compares with gleam-mist (also BEAM) and traditional frameworks.
11 changes: 11 additions & 0 deletions frameworks/phoenix/config/config.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Config

config :httparena_phoenix, HttparenaPhoenix.Endpoint,
http: [port: 8080, ip: {0, 0, 0, 0}, transport_options: [num_acceptors: 100]],
server: true,
render_errors: [formats: [json: HttparenaPhoenix.ErrorJSON]],
pubsub_server: HttparenaPhoenix.PubSub

config :logger, level: :warning

config :phoenix, :json_library, Jason
7 changes: 7 additions & 0 deletions frameworks/phoenix/config/prod.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Config

config :httparena_phoenix, HttparenaPhoenix.Endpoint,
http: [port: 8080, ip: {0, 0, 0, 0}, transport_options: [num_acceptors: 100]],
server: true

config :logger, level: :warning
4 changes: 4 additions & 0 deletions frameworks/phoenix/config/runtime.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import Config

config :httparena_phoenix, HttparenaPhoenix.Endpoint,
server: true
16 changes: 16 additions & 0 deletions frameworks/phoenix/lib/httparena_phoenix/application.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
defmodule HttparenaPhoenix.Application do
use Application

@impl true
def start(_type, _args) do
# Pre-load data
HttparenaPhoenix.DataLoader.load()

children = [
HttparenaPhoenix.Endpoint
]

opts = [strategy: :one_for_one, name: HttparenaPhoenix.Supervisor]
Supervisor.start_link(children, opts)
end
end
188 changes: 188 additions & 0 deletions frameworks/phoenix/lib/httparena_phoenix/bench_controller.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
defmodule HttparenaPhoenix.BenchController do
use Phoenix.Controller

import Plug.Conn

@db_query "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN ?1 AND ?2 LIMIT 50"

def pipeline(conn, _params) do
conn
|> put_resp_header("server", "phoenix")
|> put_resp_header("content-type", "text/plain")
|> send_resp(200, "ok")
end

def baseline11(conn, params) do
query_sum = sum_query_params(params)

body_val =
case conn.method do
"POST" ->
{:ok, body, _conn} = read_body(conn)
case Integer.parse(String.trim(body)) do
{n, _} -> n
:error -> 0
end
_ -> 0
end

total = query_sum + body_val

conn
|> put_resp_header("server", "phoenix")
|> put_resp_header("content-type", "text/plain")
|> send_resp(200, Integer.to_string(total))
end

def baseline2(conn, params) do
total = sum_query_params(params)

conn
|> put_resp_header("server", "phoenix")
|> put_resp_header("content-type", "text/plain")
|> send_resp(200, Integer.to_string(total))
end

def json(conn, _params) do
json_cache = :persistent_term.get(:json_cache)

conn
|> put_resp_header("server", "phoenix")
|> put_resp_header("content-type", "application/json")
|> send_resp(200, json_cache)
end

def compression(conn, _params) do
accepts_gzip =
case get_req_header(conn, "accept-encoding") do
[val | _] -> String.contains?(val, "gzip")
_ -> false
end

if accepts_gzip do
gzip_cache = :persistent_term.get(:gzip_large_cache)

conn
|> put_resp_header("server", "phoenix")
|> put_resp_header("content-encoding", "gzip")
|> put_resp_header("content-type", "application/json")
|> send_resp(200, gzip_cache)
else
json_large_cache = :persistent_term.get(:json_large_cache)

conn
|> put_resp_header("server", "phoenix")
|> put_resp_header("content-type", "application/json")
|> send_resp(200, json_large_cache)
end
end

def upload(conn, _params) do
{:ok, body, conn} = read_body(conn, length: 25_000_000)
size = byte_size(body)

conn
|> put_resp_header("server", "phoenix")
|> put_resp_header("content-type", "text/plain")
|> send_resp(200, Integer.to_string(size))
end

def db(conn, params) do
db_available = :persistent_term.get(:db_available)

unless db_available do
conn
|> put_resp_header("server", "phoenix")
|> put_resp_header("content-type", "application/json")
|> send_resp(200, ~s({"items":[],"count":0}))
else
min_val = parse_float(params["min"], 10.0)
max_val = parse_float(params["max"], 50.0)

{:ok, db_conn} = Exqlite.Sqlite3.open("/data/benchmark.db", [:readonly])
:ok = Exqlite.Sqlite3.execute(db_conn, "PRAGMA mmap_size=268435456")
{:ok, stmt} = Exqlite.Sqlite3.prepare(db_conn, @db_query)
:ok = Exqlite.Sqlite3.bind(stmt, [min_val, max_val])

rows = fetch_all_rows(db_conn, stmt, [])
:ok = Exqlite.Sqlite3.release(db_conn, stmt)
:ok = Exqlite.Sqlite3.close(db_conn)

items = Enum.map(rows, fn [id, name, category, price, quantity, active, tags_str, rating_score, rating_count] ->
tags = case Jason.decode(tags_str) do
{:ok, t} when is_list(t) -> t
_ -> []
end

%{
"id" => id,
"name" => name,
"category" => category,
"price" => price,
"quantity" => quantity,
"active" => active != 0,
"tags" => tags,
"rating" => %{"score" => rating_score, "count" => rating_count}
}
end)

body = Jason.encode!(%{"items" => items, "count" => length(items)})

conn
|> put_resp_header("server", "phoenix")
|> put_resp_header("content-type", "application/json")
|> send_resp(200, body)
end
end

def static_file(conn, %{"filename" => filename}) do
static_files = :persistent_term.get(:static_files)

case Map.get(static_files, filename) do
nil ->
conn
|> put_resp_header("server", "phoenix")
|> send_resp(404, "Not Found")

%{data: data, content_type: ct} ->
conn
|> put_resp_header("server", "phoenix")
|> put_resp_header("content-type", ct)
|> send_resp(200, data)
end
end

# Helpers

defp sum_query_params(params) do
params
|> Enum.reduce(0, fn
{"filename", _v}, acc -> acc
{_k, v}, acc ->
case Integer.parse(v) do
{n, ""} -> acc + n
_ -> acc
end
end)
end

defp parse_float(nil, default), do: default
defp parse_float(val, default) when is_binary(val) do
case Float.parse(val) do
{f, _} -> f
:error ->
case Integer.parse(val) do
{i, _} -> i * 1.0
:error -> default
end
end
end
defp parse_float(_, default), do: default

defp fetch_all_rows(db_conn, stmt, acc) do
case Exqlite.Sqlite3.step(db_conn, stmt) do
{:row, row} -> fetch_all_rows(db_conn, stmt, [row | acc])
:done -> Enum.reverse(acc)
end
end
end
75 changes: 75 additions & 0 deletions frameworks/phoenix/lib/httparena_phoenix/data_loader.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
defmodule HttparenaPhoenix.DataLoader do
@moduledoc """
Pre-loads and caches dataset, large dataset, static files, and pre-computed JSON/gzip responses.
"""

def load do
dataset_path = System.get_env("DATASET_PATH") || "/data/dataset.json"

dataset = load_json(dataset_path)
large_dataset = load_json("/data/dataset-large.json")

json_cache = build_json_response(dataset)
json_large_cache = build_json_response(large_dataset)
gzip_large_cache = :zlib.gzip(json_large_cache)

static_files = load_static_files()

db_available = File.exists?("/data/benchmark.db")

:persistent_term.put(:dataset, dataset)
:persistent_term.put(:json_cache, json_cache)
:persistent_term.put(:json_large_cache, json_large_cache)
:persistent_term.put(:gzip_large_cache, gzip_large_cache)
:persistent_term.put(:static_files, static_files)
:persistent_term.put(:db_available, db_available)
end

defp load_json(path) do
case File.read(path) do
{:ok, data} ->
case Jason.decode(data) do
{:ok, items} when is_list(items) -> items
_ -> []
end
_ -> []
end
end

defp build_json_response(dataset) do
items = Enum.map(dataset, fn d ->
total = Float.round(d["price"] * d["quantity"] * 1.0, 2)
Map.put(d, "total", total)
end)

Jason.encode!(%{"items" => items, "count" => length(items)})
end

defp load_static_files do
case File.ls("/data/static") do
{:ok, entries} ->
Enum.reduce(entries, %{}, fn name, acc ->
path = "/data/static/#{name}"
case File.read(path) do
{:ok, data} ->
Map.put(acc, name, %{data: data, content_type: get_mime(name)})
_ -> acc
end
end)
_ -> %{}
end
end

defp get_mime(filename) do
cond do
String.ends_with?(filename, ".css") -> "text/css"
String.ends_with?(filename, ".js") -> "application/javascript"
String.ends_with?(filename, ".html") -> "text/html"
String.ends_with?(filename, ".woff2") -> "font/woff2"
String.ends_with?(filename, ".svg") -> "image/svg+xml"
String.ends_with?(filename, ".webp") -> "image/webp"
String.ends_with?(filename, ".json") -> "application/json"
true -> "application/octet-stream"
end
end
end
5 changes: 5 additions & 0 deletions frameworks/phoenix/lib/httparena_phoenix/endpoint.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
defmodule HttparenaPhoenix.Endpoint do
use Phoenix.Endpoint, otp_app: :httparena_phoenix

plug HttparenaPhoenix.Router
end
5 changes: 5 additions & 0 deletions frameworks/phoenix/lib/httparena_phoenix/error_json.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
defmodule HttparenaPhoenix.ErrorJSON do
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end
21 changes: 21 additions & 0 deletions frameworks/phoenix/lib/httparena_phoenix/router.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
defmodule HttparenaPhoenix.Router do
use Phoenix.Router

pipeline :bench do
plug :fetch_query_params
end

scope "/", HttparenaPhoenix do
pipe_through :bench

get "/pipeline", BenchController, :pipeline
get "/baseline11", BenchController, :baseline11
post "/baseline11", BenchController, :baseline11
get "/baseline2", BenchController, :baseline2
get "/json", BenchController, :json
get "/compression", BenchController, :compression
get "/db", BenchController, :db
post "/upload", BenchController, :upload
get "/static/:filename", BenchController, :static_file
end
end
Loading
Loading