From 09bcae7250079397f2c4f69153bba2e3b3e98f81 Mon Sep 17 00:00:00 2001 From: Hitalo Souza Date: Mon, 15 Jun 2026 09:51:07 -0300 Subject: [PATCH] =?UTF-8?q?vanilla:=20DB=20path=20=E2=80=94=20prepared=20s?= =?UTF-8?q?tatement=20for=20async-db=20+=20single-pass=20HTML=20escape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two DB-path improvements (both backends): 1. async-db now uses a PostgreSQL prepared statement (PQprepare/PQexecPrepared via db.pg) instead of exec_param_many (PQexecParams), which re-parses the SQL server-side on every request. Lazily prepared on each pooled connection's first use — prepared statements are per-session and the pool reuses sessions, so after warmup every connection serves exec_prepared. 2. escape_html (fortunes) does ONE pass with a fast path (return the message untouched, no allocation, when it has no special chars) instead of replace_each's five full-string scans + reallocations. Local before/after (16-core loopback, gcannon, single listener): fortunes ~5.6K -> ~7.1K req/s (+27%, from the escape rewrite) async-db ~18.0K -> ~19.6K req/s (+9%, from the prepared statement) These are incremental: the DB profiles remain bound by the stdlib db.pg driver (text protocol, lazy pool) — the top DB engines use native binary-protocol drivers — so this narrows the gap without closing it. Correctness verified (async-db rows + count correct; fortunes renders the same rows). Co-Authored-By: Claude Opus 4.8 (1M context) --- frameworks/vanilla-epoll/main.v | 45 ++++++++++++++++++++++++++---- frameworks/vanilla-io_uring/main.v | 45 ++++++++++++++++++++++++++---- 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/frameworks/vanilla-epoll/main.v b/frameworks/vanilla-epoll/main.v index 1f8e8e0ed..2e93d8b72 100644 --- a/frameworks/vanilla-epoll/main.v +++ b/frameworks/vanilla-epoll/main.v @@ -386,7 +386,31 @@ fn (mut sh Shared) fortunes() string { } fn escape_html(s string) string { - return s.replace_each(['&', '&', '<', '<', '>', '>', '"', '"', "'", ''']) + // Fast path: most fortune messages contain no special characters, so return + // the original with no allocation instead of replace_each's 5 full-string + // passes (each scanning + reallocating). Only escape when there's something to. + mut needs := false + for c in s { + if c == `&` || c == `<` || c == `>` || c == `"` || c == `'` { + needs = true + break + } + } + if !needs { + return s + } + mut b := strings.new_builder(s.len + 16) + for c in s { + match c { + `&` { b.write_string('&') } + `<` { b.write_string('<') } + `>` { b.write_string('>') } + `"` { b.write_string('"') } + `'` { b.write_string(''') } + else { b.write_u8(c) } + } + } + return b.str() } // digits returns the number of decimal digits in a non-negative integer. @@ -412,10 +436,21 @@ fn (mut sh Shared) async_db(min i64, max i64, limit i64) string { lim = 50 } mut conn := sh.pool.acquire() or { return '{"items":[],"count":0}' } - rows := conn.exec_param_many('SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN \$1 AND \$2 LIMIT \$3', - [min.str(), max.str(), lim.str()]) or { - sh.pool.release(conn) - return '{"items":[],"count":0}' + // Prepared statement: PostgreSQL parses the SQL once per connection instead of + // on every request (exec_param_many re-parses each call). Lazily prepare on the + // connection's first use — prepared statements are per-session and the pool + // reuses sessions, so after warmup every connection serves exec_prepared. + adb_params := [min.str(), max.str(), lim.str()] + rows := conn.exec_prepared('vanilla_async_db', adb_params) or { + conn.prepare('vanilla_async_db', 'SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN \$1 AND \$2 LIMIT \$3', + 3) or { + sh.pool.release(conn) + return '{"items":[],"count":0}' + } + conn.exec_prepared('vanilla_async_db', adb_params) or { + sh.pool.release(conn) + return '{"items":[],"count":0}' + } } sh.pool.release(conn) mut items := []DbItem{cap: rows.len} diff --git a/frameworks/vanilla-io_uring/main.v b/frameworks/vanilla-io_uring/main.v index c61da0d9a..6ecc98fd9 100644 --- a/frameworks/vanilla-io_uring/main.v +++ b/frameworks/vanilla-io_uring/main.v @@ -386,7 +386,31 @@ fn (mut sh Shared) fortunes() string { } fn escape_html(s string) string { - return s.replace_each(['&', '&', '<', '<', '>', '>', '"', '"', "'", ''']) + // Fast path: most fortune messages contain no special characters, so return + // the original with no allocation instead of replace_each's 5 full-string + // passes (each scanning + reallocating). Only escape when there's something to. + mut needs := false + for c in s { + if c == `&` || c == `<` || c == `>` || c == `"` || c == `'` { + needs = true + break + } + } + if !needs { + return s + } + mut b := strings.new_builder(s.len + 16) + for c in s { + match c { + `&` { b.write_string('&') } + `<` { b.write_string('<') } + `>` { b.write_string('>') } + `"` { b.write_string('"') } + `'` { b.write_string(''') } + else { b.write_u8(c) } + } + } + return b.str() } // digits returns the number of decimal digits in a non-negative integer. @@ -412,10 +436,21 @@ fn (mut sh Shared) async_db(min i64, max i64, limit i64) string { lim = 50 } mut conn := sh.pool.acquire() or { return '{"items":[],"count":0}' } - rows := conn.exec_param_many('SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN \$1 AND \$2 LIMIT \$3', - [min.str(), max.str(), lim.str()]) or { - sh.pool.release(conn) - return '{"items":[],"count":0}' + // Prepared statement: PostgreSQL parses the SQL once per connection instead of + // on every request (exec_param_many re-parses each call). Lazily prepare on the + // connection's first use — prepared statements are per-session and the pool + // reuses sessions, so after warmup every connection serves exec_prepared. + adb_params := [min.str(), max.str(), lim.str()] + rows := conn.exec_prepared('vanilla_async_db', adb_params) or { + conn.prepare('vanilla_async_db', 'SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN \$1 AND \$2 LIMIT \$3', + 3) or { + sh.pool.release(conn) + return '{"items":[],"count":0}' + } + conn.exec_prepared('vanilla_async_db', adb_params) or { + sh.pool.release(conn) + return '{"items":[],"count":0}' + } } sh.pool.release(conn) mut items := []DbItem{cap: rows.len}