Skip to content
Draft
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
1 change: 1 addition & 0 deletions frameworks/roda/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ source 'https://rubygems.org'
gem 'roda', '~> 3.102'
gem 'puma', '~> 8.0'
gem 'pg', '~> 1.5'
gem 'redis'
gem 'json'
gem 'concurrent-ruby'
gem 'connection_pool'
9 changes: 8 additions & 1 deletion frameworks/roda/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ GEM
puma (8.0.1)
nio4r (~> 2.0)
rack (3.2.5)
redis (5.4.1)
redis-client (>= 0.22.0)
redis-client (0.29.0)
connection_pool
roda (3.102.0)
rack

Expand All @@ -33,6 +37,7 @@ DEPENDENCIES
json
pg (~> 1.5)
puma (~> 8.0)
redis
roda (~> 3.102)

CHECKSUMS
Expand All @@ -45,7 +50,9 @@ CHECKSUMS
pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d
puma (8.0.1) sha256=7b94e50c07655718c1fb8ae41a11fc06c7d61293208b3aa608ff71a46d3ad37c
rack (3.2.5) sha256=4cbd0974c0b79f7a139b4812004a62e4c60b145cba76422e288ee670601ed6d3
redis (5.4.1) sha256=b5e675b57ad22b15c9bcc765d5ac26f60b675408af916d31527af9bd5a81faae
redis-client (0.29.0) sha256=0c65bf1f8f6dca22063ddb085c0bb2054feef6f03a84869f4161b18a9a15bea3
roda (3.102.0) sha256=b2156fff6d2b1b52bfac39e4ccde0d820a26594f069c3d9e99cc0853f7ee7dcc

BUNDLED WITH
4.0.6
4.0.10
156 changes: 144 additions & 12 deletions frameworks/roda/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,25 @@ class App < Roda
opts[:dataset_items] = items
end

PG_QUERY = 'SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN $1 AND $2 LIMIT $3'.freeze
CRUD_COLUMNS = 'id, name, category, price, quantity, active, tags, rating_score, rating_count'
SELECT_QUERY = "SELECT #{CRUD_COLUMNS} FROM items WHERE price BETWEEN $1 AND $2 LIMIT $3".freeze
CRUD_GET_SQL = "SELECT #{CRUD_COLUMNS} FROM items WHERE id = $1 LIMIT 1"
CRUD_LIST_SQL = "SELECT #{CRUD_COLUMNS} FROM items WHERE category = $1 ORDER BY id LIMIT $2 OFFSET $3"
CRUD_UPDATE_SQL = "UPDATE items SET name = $1, price = $2, quantity = $3 WHERE id = $4"
CRUD_UPSERT_SQL = <<~SQL
INSERT INTO items
(#{CRUD_COLUMNS})
VALUES ($1, $2, $3, $4, $5, true, '[\"bench\"]', 0, 0)
ON CONFLICT (id) DO UPDATE SET name = $2, price = $4, quantity = $5
RETURNING id
SQL

plugin :public, root: DATA_DIR, gzip: true, brotli: true
plugin :request_headers
plugin :plain_hash_response_headers
plugin :halt
plugin :send_file
plugin :all_verbs

route do |r|
r.root { 'ok' }
Expand Down Expand Up @@ -83,20 +95,111 @@ class App < Roda
end || []

items = rows.map do |row|
{
id: row['id'],
name: row['name'],
category: row['category'],
price: row['price'],
quantity: row['quantity'],
active: row['active'] == 1,
tags: JSON.parse(row['tags']),
rating: { score: row['rating_score'], count: row['rating_count'] }
}
map_row(row)
end
render_json JSON.generate({ items: items, count: items.length })
end

r.is 'crud/items' do
r.get do
category = request.params['category'] || 'electronics'
page = (request.params['page'] || 1).to_i
limit = (request.params['limit'] || 10).to_i
offset = (page - 1) * limit

rows = self.class.get_async_db&.with do |connection|
connection.exec_prepared('crud_list', [category, limit, offset])
end || []

items = rows.map do |row|
map_row(row)
end
render_json JSON.generate({ items: items, total: items.length, page: page, limit: limit })
end

r.post do
params = JSON.parse(request.body.read)
id = params['id']
name = params['name'] || 'New Product'
category = params['category'] || 'electronics'
price = (params['price'] || 0).to_i
quantity = (params['quantity'] || 0).to_i

self.class.get_async_db&.with do |connection|
connection.exec_prepared('crud_upsert', [id, name, category, price, quantity])
end

item = {
'id' => id,
'name' => name,
'category' => category,
'price' => price,
'quantity' => quantity
}

item = map_row(item)
json = JSON.generate(item)
self.class.redis&.with do |connection|
connection.set(id, json)
end
response.status = 201
render_json json
end
end

r.is 'crud/items', Integer do |id|
r.get do
json = self.class.redis&.with do |connection|
connection.get(id.to_s)
end
if json
response['x-cache'] = 'HIT'
return render_json json
else
response['x-cache'] = 'MISS'
end

rows = self.class.get_async_db&.with do |connection|
connection.exec_prepared('crud_get', [id])
end || []

if row = rows.first
item = map_row(row)
json = JSON.generate(item)
self.class.redis&.with do |connection|
connection.set(id.to_s, json)
end
render_json json
else
r.halt 404, 'Not found'
end
end

r.put do
params = JSON.parse(request.body.read)
name = params['name'] || 'New Product'
price = (params['price'] || 0).to_i
quantity = (params['quantity'] || 0).to_i

row = self.class.get_async_db&.with do |connection|
connection.exec_prepared('crud_update', [name, price, quantity, id])
end || []

item = {
'id' => id,
'name' => name,
'price' => price,
'quantity' => quantity
}
item = map_row(item)
json = JSON.generate(item)
self.class.redis&.with do |connection|
connection.del(id.to_s)
end
render_json json
end
end

r.public
end

Expand All @@ -112,15 +215,44 @@ def render_plain(plain)
plain
end

def map_row(row)
mapped_row = {
id: row['id'],
name: row['name'],
category: row['category'],
price: row['price'],
quantity: row['quantity'],
active: row['active'] == 1,
}
mapped_row[:tags] = JSON.parse(row['tags']) if row['tags']
mapped_row[:rating] = { score: row['rating_score'], count: row['rating_count'] } if row['rating_score'] && row['rating_count']
mapped_row
end

def self.get_async_db
@async_db ||= begin
return unless ENV['DATABASE_URL']
max_connections = ENV.fetch('MAX_THREADS', 4).to_i
ConnectionPool.new(size: max_connections, timeout: 5) do
db = PG.connect(ENV['DATABASE_URL'])
db.prepare('select', PG_QUERY)
db.prepare('select', SELECT_QUERY)
db.prepare('crud_get', CRUD_GET_SQL)
db.prepare('crud_list', CRUD_LIST_SQL)
db.prepare('crud_update', CRUD_UPDATE_SQL)
db.prepare('crud_upsert', CRUD_UPSERT_SQL)
db
end
end
end

def self.redis
@redis ||= begin
return unless ENV['REDIS_URL']
max_connections = ENV.fetch('MAX_THREADS', 4).to_i
ConnectionPool::Wrapper.new(size: max_connections, timeout: 10) do
Redis.new(url: ENV['REDIS_URL'])
end
end
end

end
1 change: 1 addition & 0 deletions frameworks/roda/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"api-4",
"api-16",
"async-db",
"crud",
"static"
],
"maintainers": ["p8"]
Expand Down
37 changes: 37 additions & 0 deletions scripts/validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,43 @@ if has_test "async-db" || has_test "crud" || has_test "api-4" || has_test "api-1
docker_args+=(-e "DATABASE_MAX_CONN=256")
fi

# Start Redis sidecar if needed
if has_test "crud"; then

REDIS_CONTAINER="httparena-redis"
REDIS_URL="redis://localhost:6379"
REDIS_CPUSET="${REDIS_CPUSET:-0,64}"

echo "[redis] Starting Redis sidecar (cpuset=$REDIS_CPUSET)"
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
docker run -d --rm --name "$REDIS_CONTAINER" --network host \
--cpuset-cpus="$REDIS_CPUSET" \
--ulimit memlock=-1:-1 \
--ulimit nofile=1048576:1048576 \
redis:7-alpine \
redis-server \
--protected-mode no \
--bind 0.0.0.0 \
--port 6379 \
--save "" \
--appendonly no \
--maxmemory 512mb \
--maxmemory-policy allkeys-lru \
--io-threads 1 \
>/dev/null

# Wait for PING to succeed.
for i in $(seq 1 30); do
if docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG; then
echo "[redis] Ready"
break
fi
[ "$i" -eq 30 ] && { echo "FAIL: Redis sidecar not ready"; exit 1; }
sleep 1
done
docker_args+=(-e "REDIS_URL=redis://localhost:6379")
fi

# Start container (skip for gateway-only — compose handles it later)
if [ "$GATEWAY_ONLY" = "false" ]; then
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
Expand Down
20 changes: 20 additions & 0 deletions site/data/crud-4096.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,26 @@
"status_4xx": 0,
"status_5xx": 86954
},
{
"framework": "roda",
"language": "Ruby",
"rps": 62393,
"avg_latency": "63.38ms",
"p99_latency": "85.90ms",
"cpu": "2506.6%",
"memory": "3.3GiB",
"connections": 4096,
"threads": 64,
"duration": "5s",
"pipeline": 1,
"bandwidth": "20.27MB/s",
"input_bw": "5.36MB/s",
"reconnects": 3923,
"status_2xx": 935904,
"status_3xx": 0,
"status_4xx": 0,
"status_5xx": 0
},
{
"framework": "simplew",
"language": "C#",
Expand Down
78 changes: 78 additions & 0 deletions site/static/logs/crud/4096/roda.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
[1] Puma starting in cluster mode...
[1] * Puma version: 8.0.1 ("Into the Arena")
[1] * Ruby version: ruby 4.0.4 (2026-05-12 revision b89eb1bcbf) +YJIT +MN +PRISM [x86_64-linux]
[1] * Min threads: 4
[1] * Max threads: 4
[1] * Environment: production
[1] * Master PID: 1
[1] * Workers: 62
[1] * Restarts: (✔) hot (✖) phased (✖) refork
[1] * Preloading application
[1] * Listening on http://0.0.0.0:8080
[1] * Listening on ssl://0.0.0.0:8081?cert=/certs/server.crt&key=/certs/server.key
[1] Use Ctrl-C to stop
[1] ! WARNING: Detected `RUBY_MN_THREADS=1`
[1] ! This setting is known to cause performance regressions with Puma.
[1] ! Consider disabling this environment variable: https://github.com/puma/puma/issues/3720
[1] - Worker 0 (PID: 8) booted in 0.09s, phase: 0
[1] - Worker 1 (PID: 12) booted in 0.09s, phase: 0
[1] - Worker 2 (PID: 15) booted in 0.09s, phase: 0
[1] - Worker 3 (PID: 20) booted in 0.09s, phase: 0
[1] - Worker 4 (PID: 24) booted in 0.09s, phase: 0
[1] - Worker 5 (PID: 28) booted in 0.09s, phase: 0
[1] - Worker 6 (PID: 32) booted in 0.09s, phase: 0
[1] - Worker 7 (PID: 36) booted in 0.08s, phase: 0
[1] - Worker 8 (PID: 40) booted in 0.08s, phase: 0
[1] - Worker 9 (PID: 45) booted in 0.08s, phase: 0
[1] - Worker 10 (PID: 50) booted in 0.08s, phase: 0
[1] - Worker 11 (PID: 55) booted in 0.08s, phase: 0
[1] - Worker 12 (PID: 60) booted in 0.08s, phase: 0
[1] - Worker 13 (PID: 67) booted in 0.08s, phase: 0
[1] - Worker 14 (PID: 72) booted in 0.07s, phase: 0
[1] - Worker 15 (PID: 78) booted in 0.07s, phase: 0
[1] - Worker 16 (PID: 85) booted in 0.07s, phase: 0
[1] - Worker 17 (PID: 91) booted in 0.07s, phase: 0
[1] - Worker 18 (PID: 96) booted in 0.07s, phase: 0
[1] - Worker 19 (PID: 103) booted in 0.07s, phase: 0
[1] - Worker 20 (PID: 108) booted in 0.07s, phase: 0
[1] - Worker 21 (PID: 113) booted in 0.07s, phase: 0
[1] - Worker 22 (PID: 118) booted in 0.07s, phase: 0
[1] - Worker 23 (PID: 125) booted in 0.07s, phase: 0
[1] - Worker 24 (PID: 131) booted in 0.07s, phase: 0
[1] - Worker 25 (PID: 137) booted in 0.06s, phase: 0
[1] - Worker 26 (PID: 143) booted in 0.06s, phase: 0
[1] - Worker 27 (PID: 148) booted in 0.06s, phase: 0
[1] - Worker 28 (PID: 153) booted in 0.06s, phase: 0
[1] - Worker 29 (PID: 162) booted in 0.06s, phase: 0
[1] - Worker 30 (PID: 171) booted in 0.06s, phase: 0
[1] - Worker 31 (PID: 177) booted in 0.06s, phase: 0
[1] - Worker 32 (PID: 183) booted in 0.05s, phase: 0
[1] - Worker 33 (PID: 189) booted in 0.05s, phase: 0
[1] - Worker 34 (PID: 194) booted in 0.05s, phase: 0
[1] - Worker 35 (PID: 199) booted in 0.05s, phase: 0
[1] - Worker 36 (PID: 204) booted in 0.05s, phase: 0
[1] - Worker 37 (PID: 210) booted in 0.05s, phase: 0
[1] - Worker 38 (PID: 214) booted in 0.05s, phase: 0
[1] - Worker 39 (PID: 219) booted in 0.06s, phase: 0
[1] - Worker 40 (PID: 226) booted in 0.06s, phase: 0
[1] - Worker 41 (PID: 232) booted in 0.06s, phase: 0
[1] - Worker 42 (PID: 237) booted in 0.05s, phase: 0
[1] - Worker 43 (PID: 242) booted in 0.05s, phase: 0
[1] - Worker 44 (PID: 247) booted in 0.05s, phase: 0
[1] - Worker 45 (PID: 253) booted in 0.05s, phase: 0
[1] - Worker 46 (PID: 258) booted in 0.05s, phase: 0
[1] - Worker 47 (PID: 263) booted in 0.05s, phase: 0
[1] - Worker 48 (PID: 270) booted in 0.05s, phase: 0
[1] - Worker 49 (PID: 276) booted in 0.05s, phase: 0
[1] - Worker 50 (PID: 283) booted in 0.05s, phase: 0
[1] - Worker 51 (PID: 287) booted in 0.04s, phase: 0
[1] - Worker 52 (PID: 294) booted in 0.04s, phase: 0
[1] - Worker 53 (PID: 301) booted in 0.04s, phase: 0
[1] - Worker 54 (PID: 307) booted in 0.04s, phase: 0
[1] - Worker 55 (PID: 313) booted in 0.04s, phase: 0
[1] - Worker 56 (PID: 319) booted in 0.04s, phase: 0
[1] - Worker 57 (PID: 325) booted in 0.04s, phase: 0
[1] - Worker 58 (PID: 331) booted in 0.04s, phase: 0
[1] - Worker 59 (PID: 337) booted in 0.03s, phase: 0
[1] - Worker 60 (PID: 343) booted in 0.03s, phase: 0
[1] - Worker 61 (PID: 349) booted in 0.03s, phase: 0