Skip to content

Commit 9495b72

Browse files
authored
Merge pull request #14 from BennyFranciscus/add-kemal
Add Kemal (Crystal) framework
2 parents a855759 + 83fdc6e commit 9495b72

9 files changed

Lines changed: 360 additions & 0 deletions

File tree

frameworks/kemal/Dockerfile

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Build stage
2+
FROM crystallang/crystal:1.14-alpine AS builder
3+
4+
WORKDIR /app
5+
6+
# Install SQLite dev headers and gcc for reuseport shim
7+
RUN apk add --no-cache sqlite-dev sqlite-static gcc musl-dev
8+
9+
# Build SO_REUSEPORT shim
10+
COPY reuseport.c /tmp/reuseport.c
11+
RUN gcc -shared -fPIC -o /tmp/libreuseport.so /tmp/reuseport.c -ldl
12+
13+
# Install dependencies
14+
COPY shard.yml shard.lock ./
15+
RUN shards install --production
16+
17+
# Copy source and build
18+
COPY src/ src/
19+
RUN crystal build src/server.cr -o server --release --no-debug
20+
21+
# Runtime stage
22+
FROM alpine:3.20
23+
24+
RUN apk add --no-cache ca-certificates libgcc gc libevent sqlite-libs pcre2
25+
26+
WORKDIR /app
27+
COPY --from=builder /app/server /app/server
28+
COPY --from=builder /tmp/libreuseport.so /usr/lib/libreuseport.so
29+
COPY run.sh /app/run.sh
30+
RUN chmod +x /app/run.sh
31+
32+
EXPOSE 8080
33+
34+
ENV LD_PRELOAD=/usr/lib/libreuseport.so
35+
CMD ["/app/run.sh"]

frameworks/kemal/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Kemal (Crystal)
2+
3+
Kemal web framework for Crystal language. Crystal is a compiled language with Ruby-like syntax and C-like performance.
4+
5+
## Details
6+
7+
- **Framework:** [Kemal](https://github.com/kemalcr/kemal)
8+
- **Language:** Crystal
9+
- **Server:** Built-in (based on Crystal's HTTP::Server)
10+
- **Build:** Compiled with `--release` optimizations
11+
- **Multi-core:** Fork-based worker processes (one per CPU core)
12+
13+
## Endpoints
14+
15+
| Path | Method | Description |
16+
|---------------|-----------|------------------------------------|
17+
| /pipeline | GET | Returns "ok" |
18+
| /baseline11 | GET, POST | Sum of query params (+ body) |
19+
| /baseline2 | GET | Sum of query params |
20+
| /json | GET | Process and return dataset as JSON |
21+
| /compression | GET | Gzip-compressed large dataset |
22+
| /db | GET | SQLite query with price filtering |
23+
| /upload | POST | Returns body byte length |

frameworks/kemal/build.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/bin/bash
2+
set -euo pipefail
3+
cd "$(dirname "$0")"
4+
docker build --network host -t httparena-kemal .

frameworks/kemal/meta.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"display_name": "kemal",
3+
"language": "Crystal",
4+
"type": "framework",
5+
"engine": "Kemal",
6+
"description": "Kemal web framework for Crystal, compiled with --release optimizations.",
7+
"repo": "https://github.com/kemalcr/kemal",
8+
"enabled": true,
9+
"tests": [
10+
"baseline",
11+
"pipelined",
12+
"json",
13+
"upload",
14+
"compression",
15+
"noisy",
16+
"mixed"
17+
]
18+
}

frameworks/kemal/reuseport.c

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/* LD_PRELOAD shim: set SO_REUSEPORT on every SOCK_STREAM bind() */
2+
#define _GNU_SOURCE
3+
#include <dlfcn.h>
4+
#include <sys/socket.h>
5+
#include <sys/types.h>
6+
7+
typedef int (*bind_fn)(int, const struct sockaddr *, socklen_t);
8+
9+
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen) {
10+
int optval = 1;
11+
setsockopt(sockfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));
12+
bind_fn real_bind = (bind_fn)dlsym(RTLD_NEXT, "bind");
13+
return real_bind(sockfd, addr, addrlen);
14+
}

frameworks/kemal/run.sh

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#!/bin/sh
2+
# Multi-process launcher: one worker per CPU core with SO_REUSEPORT
3+
NPROC=$(nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo 2>/dev/null || echo 1)
4+
5+
if [ "$NPROC" -le 1 ]; then
6+
exec /app/server
7+
fi
8+
9+
PIDS=""
10+
cleanup() {
11+
for pid in $PIDS; do
12+
kill "$pid" 2>/dev/null
13+
done
14+
wait
15+
exit 0
16+
}
17+
trap cleanup INT TERM
18+
19+
i=0
20+
while [ "$i" -lt "$NPROC" ]; do
21+
/app/server &
22+
PIDS="$PIDS $!"
23+
i=$((i + 1))
24+
done
25+
26+
wait

frameworks/kemal/shard.lock

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
version: 2.0
2+
shards:
3+
backtracer:
4+
git: https://github.com/sija/backtracer.cr.git
5+
version: 1.2.4
6+
7+
db:
8+
git: https://github.com/crystal-lang/crystal-db.git
9+
version: 0.14.0
10+
11+
exception_page:
12+
git: https://github.com/crystal-loot/exception_page.git
13+
version: 0.5.0
14+
15+
kemal:
16+
git: https://github.com/kemalcr/kemal.git
17+
version: 1.10.0
18+
19+
radix:
20+
git: https://github.com/luislavena/radix.git
21+
version: 0.4.1
22+
23+
sqlite3:
24+
git: https://github.com/crystal-lang/crystal-sqlite3.git
25+
version: 0.22.0
26+

frameworks/kemal/shard.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
name: httparena-kemal
2+
version: 0.1.0
3+
4+
targets:
5+
server:
6+
main: src/server.cr
7+
8+
dependencies:
9+
kemal:
10+
github: kemalcr/kemal
11+
version: ~> 1.5
12+
sqlite3:
13+
github: crystal-lang/crystal-sqlite3
14+
version: ~> 0.21
15+
16+
crystal: ">= 1.10.0"

frameworks/kemal/src/server.cr

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
require "kemal"
2+
require "json"
3+
require "compress/gzip"
4+
require "sqlite3"
5+
6+
# ---------------------------------------------------------------------------
7+
# Startup: load datasets once
8+
# ---------------------------------------------------------------------------
9+
10+
DATASET_PATH = ENV.fetch("DATASET_PATH", "/data/dataset.json")
11+
LARGE_DATASET_PATH = "/data/dataset-large.json"
12+
DB_PATH = "/data/benchmark.db"
13+
DB_QUERY = "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN ? AND ? LIMIT 50"
14+
15+
# Process a raw JSON array of items → add "total" field
16+
def process_items(raw : Array(JSON::Any)) : Array(JSON::Any)
17+
raw.map do |item|
18+
obj = item.as_h.dup
19+
price = item["price"].as_f
20+
quantity = item["quantity"].as_i
21+
obj["total"] = JSON::Any.new((price * quantity * 100).round / 100)
22+
JSON::Any.new(obj)
23+
end
24+
end
25+
26+
# Small dataset – processed per-request like Flask does
27+
DATASET_ITEMS = begin
28+
if File.exists?(DATASET_PATH)
29+
Array(JSON::Any).from_json(File.read(DATASET_PATH))
30+
else
31+
nil
32+
end
33+
rescue
34+
nil
35+
end
36+
37+
# Large dataset – pre-process and pre-compress at startup
38+
LARGE_GZIP_BUF = begin
39+
if File.exists?(LARGE_DATASET_PATH)
40+
raw = Array(JSON::Any).from_json(File.read(LARGE_DATASET_PATH))
41+
items = process_items(raw)
42+
payload = {items: items, count: items.size}.to_json
43+
io = IO::Memory.new
44+
Compress::Gzip::Writer.open(io, level: Compress::Gzip::BEST_SPEED) do |gz|
45+
gz.print payload
46+
end
47+
io.to_slice.dup
48+
else
49+
nil
50+
end
51+
rescue
52+
nil
53+
end
54+
55+
DB_AVAILABLE = File.exists?(DB_PATH)
56+
57+
# ---------------------------------------------------------------------------
58+
# Helpers
59+
# ---------------------------------------------------------------------------
60+
61+
macro server_header(env)
62+
{{env}}.response.headers["Server"] = "kemal"
63+
end
64+
65+
# Thread-local (fiber-local) DB connections
66+
class DBPool
67+
@@connections = Hash(UInt64, DB::Database).new
68+
69+
def self.get : DB::Database
70+
fid = Fiber.current.object_id
71+
@@connections[fid] ||= DB.open("sqlite3://#{DB_PATH}?mode=ro")
72+
end
73+
end
74+
75+
# ---------------------------------------------------------------------------
76+
# Routes
77+
# ---------------------------------------------------------------------------
78+
79+
get "/pipeline" do |env|
80+
server_header(env)
81+
env.response.content_type = "text/plain"
82+
"ok"
83+
end
84+
85+
get "/baseline11" do |env|
86+
server_header(env)
87+
total = 0_i64
88+
env.params.query.each do |_key, value|
89+
total += value.to_i64 rescue next
90+
end
91+
env.response.content_type = "text/plain"
92+
total.to_s
93+
end
94+
95+
post "/baseline11" do |env|
96+
server_header(env)
97+
total = 0_i64
98+
env.params.query.each do |_key, value|
99+
total += value.to_i64 rescue next
100+
end
101+
body = env.request.body.try(&.gets_to_end)
102+
if body && !body.empty?
103+
begin
104+
total += body.strip.to_i64
105+
rescue
106+
end
107+
end
108+
env.response.content_type = "text/plain"
109+
total.to_s
110+
end
111+
112+
get "/baseline2" do |env|
113+
server_header(env)
114+
total = 0_i64
115+
env.params.query.each do |_key, value|
116+
total += value.to_i64 rescue next
117+
end
118+
env.response.content_type = "text/plain"
119+
total.to_s
120+
end
121+
122+
get "/json" do |env|
123+
server_header(env)
124+
if items_raw = DATASET_ITEMS
125+
items = process_items(items_raw)
126+
env.response.content_type = "application/json"
127+
{items: items, count: items.size}.to_json
128+
else
129+
env.response.status_code = 500
130+
"No dataset"
131+
end
132+
end
133+
134+
get "/compression" do |env|
135+
server_header(env)
136+
if buf = LARGE_GZIP_BUF
137+
env.response.content_type = "application/json"
138+
env.response.headers["Content-Encoding"] = "gzip"
139+
env.response.write(buf)
140+
nil
141+
else
142+
env.response.status_code = 500
143+
"No dataset"
144+
end
145+
end
146+
147+
get "/db" do |env|
148+
server_header(env)
149+
env.response.content_type = "application/json"
150+
151+
unless DB_AVAILABLE
152+
next %({"items":[],"count":0})
153+
end
154+
155+
min_val = (env.params.query["min"]?.try(&.to_f) || 10.0)
156+
max_val = (env.params.query["max"]?.try(&.to_f) || 50.0)
157+
158+
db = DBPool.get
159+
items = [] of Hash(String, JSON::Any)
160+
161+
db.query(DB_QUERY, min_val, max_val) do |rs|
162+
rs.each do
163+
item = Hash(String, JSON::Any).new
164+
item["id"] = JSON::Any.new(rs.read(Int64))
165+
item["name"] = JSON::Any.new(rs.read(String))
166+
item["category"] = JSON::Any.new(rs.read(String))
167+
item["price"] = JSON::Any.new(rs.read(Float64))
168+
item["quantity"] = JSON::Any.new(rs.read(Int64))
169+
item["active"] = JSON::Any.new(rs.read(Int64) != 0)
170+
tags_raw = rs.read(String)
171+
item["tags"] = JSON::Any.new(Array(JSON::Any).from_json(tags_raw))
172+
rating_score = rs.read(Float64)
173+
rating_count = rs.read(Int64)
174+
item["rating"] = JSON::Any.new({"score" => JSON::Any.new(rating_score), "count" => JSON::Any.new(rating_count)})
175+
items << item
176+
end
177+
end
178+
179+
{items: items, count: items.size}.to_json
180+
end
181+
182+
post "/upload" do |env|
183+
server_header(env)
184+
body = env.request.body.try(&.gets_to_end) || ""
185+
env.response.content_type = "text/plain"
186+
body.bytesize.to_s
187+
end
188+
189+
# ---------------------------------------------------------------------------
190+
# Server config
191+
# ---------------------------------------------------------------------------
192+
193+
Kemal.config.port = 8080
194+
Kemal.config.env = "production"
195+
Kemal.config.shutdown_message = false
196+
Kemal.config.logging = false
197+
198+
Kemal.run

0 commit comments

Comments
 (0)