From 97ef5922111fb2cbc7efc0d4fd5ac63e2e19415a Mon Sep 17 00:00:00 2001 From: Petrik Date: Mon, 18 May 2026 21:28:30 +0200 Subject: [PATCH 1/3] [roda] Add crud --- frameworks/roda/Gemfile | 1 + frameworks/roda/Gemfile.lock | 9 +- frameworks/roda/app.rb | 156 ++++++++++++++++++++++++++++++++--- frameworks/roda/meta.json | 1 + 4 files changed, 154 insertions(+), 13 deletions(-) diff --git a/frameworks/roda/Gemfile b/frameworks/roda/Gemfile index 6882018b2..cec4bfaab 100644 --- a/frameworks/roda/Gemfile +++ b/frameworks/roda/Gemfile @@ -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' diff --git a/frameworks/roda/Gemfile.lock b/frameworks/roda/Gemfile.lock index 8be0278b1..c7f247653 100644 --- a/frameworks/roda/Gemfile.lock +++ b/frameworks/roda/Gemfile.lock @@ -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 @@ -33,6 +37,7 @@ DEPENDENCIES json pg (~> 1.5) puma (~> 8.0) + redis roda (~> 3.102) CHECKSUMS @@ -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 diff --git a/frameworks/roda/app.rb b/frameworks/roda/app.rb index 60b2b5289..d22cdfaf6 100644 --- a/frameworks/roda/app.rb +++ b/frameworks/roda/app.rb @@ -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' } @@ -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 @@ -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 diff --git a/frameworks/roda/meta.json b/frameworks/roda/meta.json index f8cc868aa..c811598a4 100644 --- a/frameworks/roda/meta.json +++ b/frameworks/roda/meta.json @@ -17,6 +17,7 @@ "api-4", "api-16", "async-db", + "crud", "static" ], "maintainers": ["p8"] From 18c2a34c9c9d733437e3d6a788f5dd570872a3bb Mon Sep 17 00:00:00 2001 From: Petrik Date: Tue, 19 May 2026 22:09:03 +0200 Subject: [PATCH 2/3] Start redis in validation script if needed --- scripts/validate.sh | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/scripts/validate.sh b/scripts/validate.sh index 6b31cf5b8..49e9fe24d 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -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_URL") +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 From fe8531f8f7512b0bd5869fcd75f5ccca5743e6fd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 19 May 2026 20:39:15 +0000 Subject: [PATCH 3/3] Benchmark results: roda crud --- site/data/crud-4096.json | 20 ++++++++ site/static/logs/crud/4096/roda.log | 78 +++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 site/static/logs/crud/4096/roda.log diff --git a/site/data/crud-4096.json b/site/data/crud-4096.json index f7904d7e1..323a8ecf6 100644 --- a/site/data/crud-4096.json +++ b/site/data/crud-4096.json @@ -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#", diff --git a/site/static/logs/crud/4096/roda.log b/site/static/logs/crud/4096/roda.log new file mode 100644 index 000000000..18ce00eff --- /dev/null +++ b/site/static/logs/crud/4096/roda.log @@ -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