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
16 changes: 16 additions & 0 deletions frameworks/kemal/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
FROM crystallang/crystal:1.21.0 AS build
WORKDIR /app
COPY shard.yml shard.lock ./
RUN shards install --production --frozen
COPY src ./src
RUN crystal build src/server.cr -o /app/server --release --no-debug

# Same distribution as the build image, so the binary finds the libraries it
# was linked against. Only the shared libraries Crystal needs, no toolchain.
FROM ubuntu:24.04
RUN apt-get update && \
apt-get install -y --no-install-recommends libssl3t64 libpcre2-8-0 zlib1g && \
rm -rf /var/lib/apt/lists/*
COPY --from=build /app/server /server
EXPOSE 8080
CMD ["/server"]
33 changes: 33 additions & 0 deletions frameworks/kemal/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# kemal

Kemal on the Crystal `HTTP::Server`, default configuration.

## Stack

- **Language:** Crystal 1.21
- **Framework:** Kemal 1.12
- **Build:** `crystallang/crystal:1.21.0` in release mode, binary shipped on `ubuntu:24.04`

## Endpoints

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/pipeline` | GET | Returns `ok` (plain text) |
| `/baseline11` | GET | Sums query parameter values |
| `/baseline11` | POST | Sums query parameters + request body |
| `/json/{count}?m=N` | GET | First `count` dataset items with `total = price * quantity * m` |
| `/upload` | POST | Reads the body and returns the byte count |

## Notes

- Routing and path parameters through the Kemal radix tree router
- JSON written straight into the response with `JSON::Builder`, so the items are never held twice
- Compression through the `HTTP::CompressHandler` that `gzip true` installs, with its defaults
- One process per core, all of them accepting on port 8080 with `SO_REUSEPORT`, because a Crystal
program serves on a single thread
- The worker count is read from the cgroup files first, so both `--cpus` and `--cpuset-cpus` size it
right, and only falls back to the host core count
- The dataset is read at startup from `DATASET_PATH` or `/data/dataset.json`. A missing file serves
an empty list instead of stopping the server
- The two POST endpoints read the request stream themselves, so nothing goes through the Kemal param
parser and the 20 MB uploads are never buffered
19 changes: 19 additions & 0 deletions frameworks/kemal/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"display_name": "kemal",
"language": "Crystal",
"type": "flagship",
"mode": "standard",
"engine": "crystal-http",
"description": "Kemal 1.12 on the Crystal HTTP::Server, default configuration, one process per core sharing port 8080 through SO_REUSEPORT. Routing through the Kemal radix tree, JSON written into the response with JSON::Builder, gzip through the handler that gzip true installs.",
"repo": "https://github.com/kemalcr/kemal",
"enabled": true,
"tests": [
"baseline",
"pipelined",
"limited-conn",
"json",
"json-comp",
"upload"
],
"maintainers": []
}
18 changes: 18 additions & 0 deletions frameworks/kemal/shard.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
version: 2.0
shards:
backtracer:
git: https://github.com/sija/backtracer.cr.git
version: 1.2.4

exception_page:
git: https://github.com/crystal-loot/exception_page.git
version: 0.5.0

kemal:
git: https://github.com/kemalcr/kemal.git
version: 1.12.0

radix:
git: https://github.com/luislavena/radix.git
version: 0.4.1

15 changes: 15 additions & 0 deletions frameworks/kemal/shard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
name: kemal-arena
version: 1.0.0

targets:
server:
main: src/server.cr

dependencies:
kemal:
github: kemalcr/kemal
version: 1.12.0

crystal: ">= 1.12.0"

license: MIT
216 changes: 216 additions & 0 deletions frameworks/kemal/src/server.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
require "kemal"

# ── Dataset ─────────────────────────────────────────────────────────────────

struct Rating
include JSON::Serializable

getter score : Int64
getter count : Int64
end

struct Item
include JSON::Serializable

getter id : Int64
getter name : String
getter category : String
getter price : Int64
getter quantity : Int64
getter active : Bool
getter tags : Array(String)
getter rating : Rating

# Stored fields as they are, rating still nested, then the computed total.
def write(json : JSON::Builder, m : Int64) : Nil
json.object do
json.field "id", @id
json.field "name", @name
json.field "category", @category
json.field "price", @price
json.field "quantity", @quantity
json.field "active", @active
json.field "tags", @tags
json.field "rating", @rating
json.field "total", @price * @quantity * m
end
end
end

# A missing or unreadable file serves an empty list, it never stops the server.
def load_items : Array(Item)
Array(Item).from_json(File.read(ENV["DATASET_PATH"]? || "/data/dataset.json"))
rescue
[] of Item
end

ITEMS = load_items

# ── Worker count ────────────────────────────────────────────────────────────
#
# Crystal serves on one thread, so the server runs one process per core and
# they all accept on the same port through SO_REUSEPORT. The count comes from
# the container limits first, the host only when there is no limit set.

private def read_first_line(path : String) : String?
File.read(path).strip
rescue
nil
end

# cgroup v2 cpu.max, then the v1 pair. This is what `docker run --cpus` sets.
private def cgroup_quota : Int32?
if line = read_first_line("/sys/fs/cgroup/cpu.max")
parts = line.split(' ')
if parts.size == 2 && parts[0] != "max"
quota = parts[0].to_i64?
period = parts[1].to_i64?
if quota && period && quota > 0 && period > 0
n = (quota // period).to_i32
return n if n >= 1
end
end
end

quota = read_first_line("/sys/fs/cgroup/cpu/cpu.cfs_quota_us").try &.to_i64?
period = read_first_line("/sys/fs/cgroup/cpu/cpu.cfs_period_us").try &.to_i64?
if quota && period && quota > 0 && period > 0
n = (quota // period).to_i32
return n if n >= 1
end

nil
end

# The cpu list of the cgroup, "0-31,64-95" style. This is `--cpuset-cpus`,
# which the benchmark uses and which the quota above does not cover.
private def cgroup_cpuset : Int32?
list = read_first_line("/sys/fs/cgroup/cpuset.cpus.effective") ||
read_first_line("/sys/fs/cgroup/cpuset/cpuset.cpus")
return nil if list.nil? || list.empty?

total = 0
list.split(',') do |part|
if part.includes?('-')
low, _, high = part.partition('-')
l = low.to_i?
h = high.to_i?
total += h - l + 1 if l && h && h >= l
elsif part.to_i?
total += 1
end
end
total > 0 ? total : nil
end

def worker_count : Int32
cgroup_quota || cgroup_cpuset || System.cpu_count.to_i32
end

# ── Routes ──────────────────────────────────────────────────────────────────

def sum_query(params : HTTP::Params) : Int64
sum = 0_i64
params.each do |_, value|
if n = value.to_i64?
sum += n
end
end
sum
end

get "/pipeline" do |env|
env.response.content_type = "text/plain"
"ok"
end

get "/baseline11" do |env|
env.response.content_type = "text/plain"
sum_query(env.params.query).to_s
end

post "/baseline11" do |env|
total = sum_query(env.params.query)
# Read the raw stream: the body is a bare number, not a form or a document
if body = env.request.body
if n = body.gets_to_end.strip.to_i64?
total += n
end
end
env.response.content_type = "text/plain"
total.to_s
end

get "/json/:count" do |env|
count = env.params.url["count"].to_i? || 0
count = 0 if count < 0
count = ITEMS.size if count > ITEMS.size
m = env.params.query["m"]?.try(&.to_i64?) || 1_i64

env.response.content_type = "application/json"
# Straight into the response, so the items are never held twice in memory.
# gzip, when the request asks for it, is the handler `gzip true` installs.
JSON.build(env.response) do |json|
json.object do
json.field "items" do
json.array do
count.times { |i| ITEMS[i].write(json, m) }
end
end
json.field "count", count
end
end
nil
end

post "/upload" do |env|
received = 0_i64
if body = env.request.body
buffer = Bytes.new(64 * 1024)
while (read = body.read(buffer)) > 0
received += read
end
end
env.response.content_type = "text/plain"
received.to_s
end

# ── Server ──────────────────────────────────────────────────────────────────

def serve
logging false
serve_static false
gzip true
Kemal.config.env = "production"
Kemal.config.powered_by_header = false

Kemal.run do |config|
# Binding here keeps Kemal from binding itself, which is how the workers
# get reuse_port and share the accept queue of port 8080.
config.server.not_nil!.bind_tcp("0.0.0.0", 8080, reuse_port: true)
end
end

workers = worker_count

if ENV["KEMAL_WORKER"]? || workers <= 1
serve
else
children = Array(Process).new(workers)
workers.times do
children << Process.new(
Process.executable_path || "/server",
env: {"KEMAL_WORKER" => "1"},
input: Process::Redirect::Inherit,
output: Process::Redirect::Inherit,
error: Process::Redirect::Inherit
)
end

Process.on_terminate do
children.each { |child| child.terminate rescue nil }
exit
end

children.each { |child| child.wait rescue nil }
end