diff --git a/docs/setup.md b/docs/setup.md index 5d78815..dff3e86 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -192,6 +192,7 @@ WASM_CORE_PATH=/bin/wasm/core.wasm WASM_MEMORY_LIMIT_BYTES=536870912 WASM_EPOCH_DEADLINE_TICKS=200 WASM_EPOCH_PERIOD_MS=50 +MYSQL_PERSISTENT_POOL_SIZE=8 WORKER_COUNT=4 MAX_MEMORY=16777216 @@ -211,6 +212,7 @@ Important settings: - `BIN_DIRECTORY` stores generated C++, wasm artifacts, compile output, and runtime caches. - `TMP_UPLOAD_PATH` and `SESSION_PATH` must be writable by the runtime. - `SESSION_COOKIE_SECURE=1` adds the `Secure` attribute to UCE-managed session cookies and should be used for HTTPS-only deployments. Leave it `0` only for local/plain-HTTP development. +- `MYSQL_PERSISTENT_POOL_SIZE` caps credential-keyed connections retained by each Wasm worker. The default `8` is clamped to `64`; set it to `0` to restore request-lifetime connections. Cached sessions are reset before reuse. - `HTTP_PORT` is the built-in HTTP/WebSocket listener used for WebSocket upgrade traffic and direct local probes. Bind/firewall it for local access only; nginx/Apache should be the public entry point. - `WS_BROKER_OUTBOUND_TIMEOUT_SECONDS` controls how long a forwarded WS message can remain queued in the broker before being dropped (default `30`). Set to `0` to disable the timeout. - `WASM_COMPILE_SCRIPT` must point to `scripts/compile_wasm_unit` unless you provide an equivalent compiler. Relative paths are resolved from the runtime root/`COMPILER_SYS_PATH`. That script calls `scripts/check_unit_wasm.py` after linking each unit and uses the pinned WASI SDK on every deployment host. diff --git a/etc/uce/settings.cfg b/etc/uce/settings.cfg index 752f230..ef8c67f 100644 --- a/etc/uce/settings.cfg +++ b/etc/uce/settings.cfg @@ -42,6 +42,7 @@ WASM_CORE_PATH=bin/wasm/core.wasm WASM_MEMORY_LIMIT_BYTES=536870912 WASM_EPOCH_DEADLINE_TICKS=200 WASM_EPOCH_PERIOD_MS=50 +MYSQL_PERSISTENT_POOL_SIZE=8 # ENABLE THE BACKGROUND PROACTIVE COMPILER LOOP PROACTIVE_COMPILE_ENABLED=1 diff --git a/scripts/run_cli_tests.sh b/scripts/run_cli_tests.sh index 166ad56..5e3e40f 100755 --- a/scripts/run_cli_tests.sh +++ b/scripts/run_cli_tests.sh @@ -71,5 +71,6 @@ if [[ "$action" == "run" ]]; then scripts/test_nested_component_props.sh scripts/test_password_hashing.sh scripts/test_mysql_epoch_refresh.sh + scripts/test_mysql_persistent_pool.sh scripts/test_log_timeliness.sh fi diff --git a/scripts/test_mysql_persistent_pool.sh b/scripts/test_mysql_persistent_pool.sh new file mode 100755 index 0000000..dbbc002 --- /dev/null +++ b/scripts/test_mysql_persistent_pool.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +test_name="mysql-persistent-pool-test-$$" +settings_file="${UCE_SETTINGS_FILE:-/etc/uce/settings.cfg}" +site_directory="${UCE_TEST_SITE_DIRECTORY:-site}" +if [[ -z "${UCE_TEST_SITE_DIRECTORY:-}" && -r "$settings_file" ]]; then + configured_site_directory=$(awk -F= '/^[[:space:]]*SITE_DIRECTORY[[:space:]]*=/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); print $2; exit}' "$settings_file") + if [[ -n "${configured_site_directory:-}" ]]; then + site_directory="$configured_site_directory" + fi +fi +source_dir="$site_directory/$test_name" +bin_directory=$(awk -F= '/^[[:space:]]*BIN_DIRECTORY[[:space:]]*=/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); print $2; exit}' "$settings_file" 2>/dev/null || true) +bin_directory="${bin_directory:-/tmp/uce/work}" +cache_dir="" +http_host="${UCE_TEST_HTTP_HOST:-uce.openfu.com}" +test_user="uce_pool_$$" +test_database="uce_pool_$$" +test_password=$(printf '%s' "$test_name-$(date +%s%N)" | sha256sum | cut -c1-32) + +cleanup() { + mariadb -e "DROP DATABASE IF EXISTS \`$test_database\`; DROP USER IF EXISTS '$test_user'@'127.0.0.1'" >/dev/null 2>&1 || true + rm -rf "$source_dir" + if [[ -n "$cache_dir" ]]; then + rm -rf "$cache_dir" + fi +} +trap cleanup EXIT +mkdir -p "$source_dir" +cache_dir="$bin_directory$(realpath "$source_dir")" +mariadb -e "DROP DATABASE IF EXISTS \`$test_database\`; DROP USER IF EXISTS '$test_user'@'127.0.0.1'; CREATE DATABASE \`$test_database\`; CREATE USER '$test_user'@'127.0.0.1' IDENTIFIED BY '$test_password'; GRANT ALL ON \`$test_database\`.* TO '$test_user'@'127.0.0.1'" + +printf '%s\n' \ + 'RENDER(Request& context)' \ + '{' \ + " MySQL* db = mysql_connect(\"127.0.0.1\", \"$test_user\", \"$test_password\");" \ + ' if(db == 0 || mysql_error(db) != "") { context.set_status(500, "MySQL connect failed"); print("connect-failed"); return; }' \ + " mysql_query(db, \"USE \\\`$test_database\\\`\");" \ + ' DValue marker_rows = mysql_query(db, "SELECT @uce_pool_marker AS marker");' \ + ' String marker; marker_rows.each([&](DValue row, String key) { marker = row["marker"].to_string(); });' \ + ' bool marker_clean = marker == "";' \ + ' mysql_query(db, "SELECT id FROM uce_persistent_temp LIMIT 1");' \ + ' bool temp_clean = mysql_error(db) != "";' \ + " mysql_query(db, \"SET @uce_pool_marker='dirty'\");" \ + ' mysql_query(db, "CREATE TEMPORARY TABLE uce_persistent_temp (id INT PRIMARY KEY)");' \ + ' DValue perf = request_perf();' \ + ' String source;' \ + ' perf["mysql_operations"].each([&](DValue operation, String key) { if(operation["op"].to_string() == "connect") source = operation["source"].to_string(); });' \ + ' print(perf["worker_pid"].to_string(), "|", source, "|", marker_clean ? "clean" : "dirty", "|", temp_clean ? "clean" : "dirty");' \ + ' mysql_disconnect(db);' \ + '}' >"$source_dir/test.uce" + +reused=0 +for _ in $(seq 1 160); do + output=$(curl -fsS --max-time 10 -H "Host: $http_host" "http://127.0.0.1/$test_name/test.uce") + if [[ "$output" == *"|worker|"* ]]; then + reused=$((reused + 1)) + if [[ "$output" != *"|worker|clean|clean" ]]; then + echo "Persistent MySQL reuse leaked cross-request state: $output" >&2 + exit 1 + fi + fi +done +if [[ "$reused" -eq 0 ]]; then + echo "Persistent MySQL pool did not produce a worker reuse in 160 requests" >&2 + exit 1 +fi + +echo "Persistent MySQL pool reset passed across $reused reused requests" diff --git a/site/demo/index.uce b/site/demo/index.uce index 6378716..b115243 100644 --- a/site/demo/index.uce +++ b/site/demo/index.uce @@ -99,6 +99,7 @@ RENDER(Request& context) print("Hostcalls: ", (u64)perf["hostcall_count"].to_u64(), " / ", (u64)perf["hostcall_us"].to_u64(), " us\n"); print("MySQL hostcalls: ", (u64)perf["mysql_hostcall_count"].to_u64(), " / ", (u64)perf["mysql_hostcall_us"].to_u64(), " us\n"); print("MySQL operations: ", json_encode(perf["mysql_operations"]), " / dropped ", (u64)perf["mysql_operations_dropped"].to_u64(), "\n"); + print("MySQL connections: ", (u64)perf["mysql_connection_open_count"].to_u64(), " new / ", (u64)perf["mysql_connection_reuse_count"].to_u64(), " worker reuse / ", (u64)perf["mysql_request_pool_hit_count"].to_u64(), " request reuse\n"); print("Memcache hostcalls: ", (u64)perf["memcache_hostcall_count"].to_u64(), " / ", (u64)perf["memcache_hostcall_us"].to_u64(), " us\n"); print("Component resolves: ", (u64)perf["component_resolve_count"].to_u64(), " / ", (u64)perf["component_resolve_us"].to_u64(), " us\n"); print("Output buffer size: ", context.ob->str().length(), "\n"); diff --git a/site/doc/pages/request_perf.txt b/site/doc/pages/request_perf.txt index 9b2594a..7835314 100644 --- a/site/doc/pages/request_perf.txt +++ b/site/doc/pages/request_perf.txt @@ -9,7 +9,9 @@ return value : performance snapshot for the active request/workspace >time_precise :content -Returns a DValue with timing and process metadata such as worker pid, parent pid, request count, request start times, native dispatch, workspace setup and birth, context application, guest execution, and hostcall timing. Hostcall totals include component resolution; MySQL, memcache, and component resolution also expose their own count and microsecond fields. `mysql_operations` is an ordered, query-text-free list of up to 64 logical MySQL operations and their microsecond durations; `mysql_operations_dropped` reports any overflow. The profiling hostcall itself is excluded from those totals so repeated snapshots remain comparable. +Returns a DValue with timing and process metadata such as worker pid, parent pid, request count, request start times, native dispatch, workspace setup and birth, context application, guest execution, and hostcall timing. Hostcall totals include component resolution; MySQL, memcache, and component resolution also expose their own count and microsecond fields. `mysql_operations` is an ordered, query-text-free list of up to 64 logical MySQL operations and their microsecond durations. A connect operation also identifies its source as `new`, cross-request `worker`, or same-request `request`; the corresponding `mysql_connection_open_count`, `mysql_connection_reuse_count`, and `mysql_request_pool_hit_count` fields provide totals. `mysql_operations_dropped` reports any overflow. The profiling hostcall itself is excluded from those totals so repeated snapshots remain comparable. + +Wasm FastCGI workers retain up to `MYSQL_PERSISTENT_POOL_SIZE` credential-keyed MySQL connections (default `8`; set `0` to disable). UCE calls the client library's connection-reset operation before another request receives a cached connection, clearing transactions, temporary tables, session variables, and selected databases while avoiding a new authentication handshake. Same-request leases continue to share state until request cleanup. :example DValue perf = request_perf(); diff --git a/site/tests/services.uce b/site/tests/services.uce index 823aac3..3e732a0 100644 --- a/site/tests/services.uce +++ b/site/tests/services.uce @@ -149,7 +149,7 @@ RENDER(Request& context) String mysql_operations = json_encode(mysql_perf["mysql_operations"]); mark( "request_perf() exposes bounded query-text-free MySQL operations", - mysql_perf["mysql_operation_count"].to_u64() > 0 && mysql_operations.find("\"connect\"") != String::npos && mysql_operations.find("\"us\"") != String::npos && mysql_operations.find("SHOW DATABASES") == String::npos && mysql_perf["mysql_operations_dropped"].to_u64() == 0 ? "pass" : "fail", + mysql_perf["mysql_operation_count"].to_u64() > 0 && mysql_operations.find("\"connect\"") != String::npos && mysql_operations.find("\"source\"") != String::npos && mysql_operations.find("\"us\"") != String::npos && mysql_operations.find("SHOW DATABASES") == String::npos && (mysql_error_text != "" || mysql_perf["mysql_connection_open_count"].to_u64() + mysql_perf["mysql_connection_reuse_count"].to_u64() > 0) && mysql_perf["mysql_operations_dropped"].to_u64() == 0 ? "pass" : "fail", mysql_operations ); diff --git a/src/lib/mysql-connector.cpp b/src/lib/mysql-connector.cpp index 8a427f6..31c6873 100644 --- a/src/lib/mysql-connector.cpp +++ b/src/lib/mysql-connector.cpp @@ -8,7 +8,7 @@ // actual close at request end, including exception/fatal recovery paths. static void mysql_register_request_connection(MySQL* db) { - if(!context || !db) + if(!context || !db || db->worker_persistent) return; auto& connections = context->resources.mysql_connections; if(std::find(connections.begin(), connections.end(), (void*)db) == connections.end()) @@ -57,6 +57,21 @@ bool MySQL::connect(String host, String username, String password) return(true); } +bool MySQL::reset_connection() +{ + if(!connection || mysql_reset_connection((MYSQL*)connection) != 0) + return(false); + _preload_next_error_code = 0; + affected_rows = 0; + field_count = 0; + row_count = 0; + insert_id = 0; + statement_info = "reset"; + field_info.clear(); + request_leases = 0; + return(true); +} + String MySQL::escape(String raw, char quote_char) { return(mysql_escape(raw, quote_char)); diff --git a/src/lib/mysql-connector.h b/src/lib/mysql-connector.h index 68ea445..2767a88 100644 --- a/src/lib/mysql-connector.h +++ b/src/lib/mysql-connector.h @@ -22,6 +22,7 @@ struct MySQL { String statement_info = ""; // bool request_cleanup_delete = false; bool request_pooled = false; + bool worker_persistent = false; u32 request_leases = 0; String request_host; String request_username; @@ -30,6 +31,7 @@ struct MySQL { std::vector field_info; bool connect(String host = "localhost", String username = "root", String password = ""); + bool reset_connection(); void disconnect(); String error(); String escape(String raw, char quote_char = '\''); diff --git a/src/wasm/backend.cpp b/src/wasm/backend.cpp index bf694d4..0ecb92c 100644 --- a/src/wasm/backend.cpp +++ b/src/wasm/backend.cpp @@ -52,6 +52,7 @@ static String wasm_backend_ensure_started(Request* context) wc.write_roots.push_back(cfg[key]); wc.memory_limit = (int64_t)to_u64(cfg["WASM_MEMORY_LIMIT_BYTES"], 512ull * 1024 * 1024); wc.epoch_deadline_ticks = to_u64(cfg["WASM_EPOCH_DEADLINE_TICKS"], 200); + wc.mysql_persistent_pool_size = std::min(to_u64(cfg["MYSQL_PERSISTENT_POOL_SIZE"], 8), 64); wc.verbose = to_bool(cfg["WASM_BACKEND_VERBOSE"], false); // UCE_HOSTCALL_BLOCKLIST: comma-separated uce_host_* names (with or without // the "uce_host_" prefix) the sysadmin disables; each blocked call traps into diff --git a/src/wasm/worker.cpp b/src/wasm/worker.cpp index a06cee4..d2c5836 100644 --- a/src/wasm/worker.cpp +++ b/src/wasm/worker.cpp @@ -92,6 +92,7 @@ struct WasmWorkerConfig int64_t memory_limit = 512ll * 1024 * 1024; u32 table_headroom = 4096; u64 epoch_deadline_ticks = 200; // ticker period × ticks = CPU budget + u64 mysql_persistent_pool_size = 8; bool verbose = false; // uce_host_* names (bare, without the "uce_host_" prefix) the sysadmin has // disabled via UCE_HOSTCALL_BLOCKLIST. A blocked hostcall resolves to a trap @@ -102,6 +103,7 @@ struct WasmWorkerConfig struct WasmMySQLOperation { String op; + String source; u64 elapsed_us = 0; }; @@ -119,6 +121,9 @@ struct WasmRequestProfile u64 mysql_hostcall_total_us = 0; u64 mysql_operation_count = 0; u64 mysql_operations_dropped = 0; + u64 mysql_connection_open_count = 0; + u64 mysql_connection_reuse_count = 0; + u64 mysql_request_pool_hit_count = 0; std::vector mysql_operations; u64 memcache_hostcall_count = 0; u64 memcache_hostcall_total_us = 0; @@ -625,6 +630,62 @@ public: { } +#ifdef UCE_WASM_HOST_CONNECTORS + ~WasmWorker() + { + for(auto* db : mysql_persistent_pool) + delete db; + } + + MySQL* mysql_checkout(const String& host, const String& username, const String& password, bool& reused, bool& persistent) + { + reused = false; + persistent = false; + for(size_t i = 0; i < mysql_persistent_pool.size(); i++) + { + MySQL* db = mysql_persistent_pool[i]; + if(!db || !db->connection || db->request_host != host || db->request_username != username || db->request_password != password) + continue; + if(db->reset_connection()) + { + if(i + 1 < mysql_persistent_pool.size()) + { + mysql_persistent_pool.erase(mysql_persistent_pool.begin() + i); + mysql_persistent_pool.push_back(db); + } + reused = true; + persistent = true; + return(db); + } + delete db; + mysql_persistent_pool.erase(mysql_persistent_pool.begin() + i); + break; + } + + MySQL* db = new MySQL(); + persistent = cfg.mysql_persistent_pool_size > 0; + db->worker_persistent = persistent; + db->request_pooled = true; + db->request_host = host; + db->request_username = username; + db->request_password = password; + if(!db->connect(host, username, password) || !db->connection) + return(db); + if(persistent) + { + while(mysql_persistent_pool.size() >= cfg.mysql_persistent_pool_size) + { + delete mysql_persistent_pool.front(); + mysql_persistent_pool.erase(mysql_persistent_pool.begin()); + } + mysql_persistent_pool.push_back(db); + } + return(db); + } + + std::vector mysql_persistent_pool; +#endif + String init() { std::vector bytes; @@ -831,6 +892,7 @@ public: std::vector sqlite_handles; std::vector mysql_handles; std::vector mysql_request_pool; + std::vector mysql_request_owned; #endif ~WasmWorkspace() { @@ -848,6 +910,9 @@ public: if(db) delete db; // ~SQLite disconnects for(auto* db : mysql_request_pool) + if(db) + db->request_leases = 0; + for(auto* db : mysql_request_owned) if(db) delete db; // ~MySQL disconnects #endif @@ -1780,11 +1845,16 @@ private: response["mysql_hostcall_us"] = (f64)self->mysql_hostcall_total_us; response["mysql_operation_count"] = (f64)self->mysql_operation_count; response["mysql_operations_dropped"] = (f64)self->mysql_operations_dropped; + response["mysql_connection_open_count"] = (f64)self->mysql_connection_open_count; + response["mysql_connection_reuse_count"] = (f64)self->mysql_connection_reuse_count; + response["mysql_request_pool_hit_count"] = (f64)self->mysql_request_pool_hit_count; response["mysql_operations"].set_array(); for(auto& operation : self->mysql_operations) { DValue item; item["op"] = operation.op; + if(operation.source != "") + item["source"] = operation.source; item["us"] = (f64)operation.elapsed_us; response["mysql_operations"].push(item); } @@ -2500,6 +2570,7 @@ private: DValue request, response; String decode_error; String op; + String connection_source; auto operation_started = std::chrono::steady_clock::now(); if(ucb_decode(encoded, request, &decode_error)) { @@ -2514,19 +2585,23 @@ private: if(pooled && pooled->connection && pooled->request_host == host && pooled->request_username == username && pooled->request_password == password) { db = pooled; + connection_source = "request"; break; } bool ok = db != 0; if(!db) { - db = new MySQL(); - db->request_pooled = true; - db->request_host = host; - db->request_username = username; - db->request_password = password; - ok = db->connect(host, username, password); + bool reused = false; + bool persistent = false; + db = self->worker.mysql_checkout(host, username, password, reused, persistent); + connection_source = reused ? "worker" : "new"; + ok = db && db->connection; if(ok && db->connection) + { self->mysql_request_pool.push_back(db); + if(!persistent) + self->mysql_request_owned.push_back(db); + } } u64 handle = 0; if(ok && db->connection) @@ -2534,11 +2609,14 @@ private: db->request_leases++; self->mysql_handles.push_back(db); handle = self->mysql_handles.size(); + if(connection_source == "new") self->mysql_connection_open_count++; + else if(connection_source == "worker") self->mysql_connection_reuse_count++; + else if(connection_source == "request") self->mysql_request_pool_hit_count++; } response["handle"] = (f64)handle; response["error_code"] = (f64)db->_preload_next_error_code; response["statement_info"] = db->error(); - if(handle == 0) + if(handle == 0 && db) delete db; } else if(op == "escape") @@ -2571,6 +2649,7 @@ private: { WasmMySQLOperation operation; operation.op = op; + operation.source = connection_source; operation.elapsed_us = (u64)std::chrono::duration_cast( std::chrono::steady_clock::now() - operation_started).count(); self->mysql_operation_count++;