From cdd5b5fbe7174351a68503472223c6cf254fb4b7 Mon Sep 17 00:00:00 2001 From: nick Date: Tue, 20 Oct 2026 09:36:01 +0800 Subject: [PATCH 1/3] test(database): add real-service acceptance for public coroutine modules --- scripts/test_databases.sh | 10 +- tests/database/CMakeLists.txt | 11 +- tests/database/README.md | 52 ++++ tests/database/README_CN.md | 42 +++ tests/database/public_service.cpp | 407 ++++++++++++++++++++++++++++++ 5 files changed, 518 insertions(+), 4 deletions(-) create mode 100644 tests/database/public_service.cpp diff --git a/scripts/test_databases.sh b/scripts/test_databases.sh index 47ea00cce..a0cae393d 100755 --- a/scripts/test_databases.sh +++ b/scripts/test_databases.sh @@ -3,14 +3,14 @@ set -euo pipefail if [[ $# -lt 1 || $# -gt 2 ]]; then - printf 'Usage: %s BUILD_DIR [local|redis|mysql|redis-awaitable|mysql-awaitable|all]\n' "$0" >&2 + printf 'Usage: %s BUILD_DIR [local|redis|mysql|redis-awaitable|mysql-awaitable|public-redis|public-mysql|public-services|all]\n' "$0" >&2 exit 2 fi database_test_dir=$(cd -- "$1" && pwd)/tests/database database_test_mode=${2:-local} case "$database_test_mode" in - local|redis|mysql|redis-awaitable|mysql-awaitable|all) ;; + local|redis|mysql|redis-awaitable|mysql-awaitable|public-redis|public-mysql|public-services|all) ;; *) printf 'Unknown database test mode: %s\n' "$database_test_mode" >&2; exit 2 ;; esac @@ -50,3 +50,9 @@ fi if [[ "$database_test_mode" == mysql-awaitable || "$database_test_mode" == all ]]; then run_database_test ark_database_service_awaitable_tests mysql fi +if [[ "$database_test_mode" == public-redis || "$database_test_mode" == public-services || "$database_test_mode" == all ]]; then + run_database_test ark_database_public_service_tests redis +fi +if [[ "$database_test_mode" == public-mysql || "$database_test_mode" == public-services || "$database_test_mode" == all ]]; then + run_database_test ark_database_public_service_tests mysql +fi diff --git a/tests/database/CMakeLists.txt b/tests/database/CMakeLists.txt index 7dc500a78..c29f11d48 100644 --- a/tests/database/CMakeLists.txt +++ b/tests/database/CMakeLists.txt @@ -1,4 +1,4 @@ -# Development-only native database bridges; no runtime plugin is enabled here. +# Development-only database checks; no runtime application plugin is enabled here. find_package(Threads REQUIRED) if(NOT EXISTS "${ARKNET_SOURCE_DIR}/include/arknet/redis/pool.hpp") message(FATAL_ERROR "Database development tests require a local arknet checkout with the native Redis pool") @@ -36,6 +36,10 @@ target_link_libraries(ark_database_redis_stop_tests PRIVATE arknet::arknet) ark_database_test(ark_database_public_redis_tests main.cpp public_redis_module.cpp "${ROOT_DIR}/src/plugins/redis/redis_module_impl.cpp") target_link_libraries(ark_database_public_redis_tests PRIVATE ark::core arknet::arknet) +# Explicit real-service acceptance; deliberately excluded from check-database. +ark_database_test(ark_database_public_service_tests public_service.cpp + "${ROOT_DIR}/src/plugins/redis/redis_module_impl.cpp") +target_link_libraries(ark_database_public_service_tests PRIVATE ark::core arknet::arknet) add_library(ark_database_redis_plugin SHARED "${ROOT_DIR}/src/plugins/redis/redis_plugin.cpp" "${ROOT_DIR}/src/plugins/redis/redis_module_impl.cpp") target_include_directories(ark_database_redis_plugin PRIVATE "${ROOT_DIR}/src/plugins") @@ -67,9 +71,12 @@ add_custom_target(check-database ark_database_task_contract_tests USES_TERMINAL) -add_dependencies(check-database ark_database_redis_sample_check) +# Compile real-service acceptance in ordinary CI without running any server work. +add_dependencies(check-database ark_database_redis_sample_check ark_database_public_service_tests) if(ARKNET_ENABLE_SSL) + target_sources(ark_database_public_service_tests PRIVATE + "${ROOT_DIR}/src/plugins/mysql/mysql_module_impl.cpp") target_compile_definitions(ark_database_public_redis_tests PRIVATE ARK_DATABASE_REDIS_CERT_DIR="${ARKNET_SOURCE_DIR}/tests/certs") ark_database_test(ark_database_public_mysql_tests main.cpp public_mysql_module.cpp diff --git a/tests/database/README.md b/tests/database/README.md index 59c121015..6a0044fef 100644 --- a/tests/database/README.md +++ b/tests/database/README.md @@ -186,3 +186,55 @@ peers. `ark_database_public_redis_plugin_tests` and `ark_database_public_mysql_plugin_tests` load actual hidden-symbol plugin libraries. The migrated samples are also compiled by `check-database`. MySQL checks require SSL; real-server targets still require explicit endpoint settings. + +## Public modules against real services + +`ark_database_public_service_tests` calls the public `mysql_module` and +`redis_module` interfaces with their real implementations, a registered consumer's +`task_group`, and the manager's shared `manual_runtime`. It is a separate C++ +executable; `check-database` compiles it without running it or connecting to a server. + +```sh +cmake --build database-build --target ark_database_public_service_tests --parallel 2 +# Configure the isolated endpoints first, then choose either protocol or both. +bash scripts/test_databases.sh database-build public-redis +bash scripts/test_databases.sh database-build public-mysql +bash scripts/test_databases.sh database-build public-services +# Equivalent direct invocations: +./database-build/tests/database/ark_database_public_service_tests redis +./database-build/tests/database/ark_database_public_service_tests mysql +``` + +Redis requires `ARK_DATABASE_REDIS_HOST` and `ARK_DATABASE_REDIS_PORT`. Optional +`ARK_DATABASE_REDIS_USER` and `ARK_DATABASE_REDIS_PASSWORD` configure authentication; +use an authenticated disposable service to verify that path. The check mutates the +caller's configuration after registration, verifies an ordered binary ECHO/ECHO/PING +pipeline and a follow-up command, and makes no writes. + +MySQL requires `ARK_DATABASE_MYSQL_HOST`, `ARK_DATABASE_MYSQL_PORT`, +`ARK_DATABASE_MYSQL_USER`, `ARK_DATABASE_MYSQL_PASSWORD` (present, possibly empty), +`ARK_DATABASE_MYSQL_DATABASE` and `ARK_DATABASE_MYSQL_CA`. TLS and peer verification +are required. Use a disposable schema and an account with CREATE, DROP, SELECT and +INSERT privileges. The check creates a unique `ark_public_service_...` InnoDB +table, exercises text queries, binary prepared results, transaction queries and +prepared inserts, then verifies committed data and the absence of rolled-back +data. It drops its table after the workflow, including assertion failures when the +connection remains usable. A failed connection, active transaction cleanup, or +forced process exit can leave the table for disposable-schema cleanup. + +Both protocols assert owner-thread/shared-executor coroutine resumption, input and +configuration ownership, and result ownership after subsequent operations and +module/runtime destruction. The manager enters natural drain with accepted work +pending; follow-up requests finish before successful, idempotent stop. There is no +cancellation or service fault injection in these real-service cases. Each protocol +also repeats its workflow with deliberate host-stack unwinding while IO is pending: +named coroutine functions own their input values, the owner destructor drains the +workflow, and the outer scope verifies its completion and retained results. +Protocol-peer and DSO regressions remain separate checks; these results do not establish dynamic-library +unloading, failure recovery or capacity acceptance. + +The executable requires explicit endpoints, returns nonzero for missing/invalid +configuration or failure, and does not print credentials or arbitrary native error +messages. It never starts or stops servers. `all` also includes both public +real-service modes and therefore requires an SSL-enabled build and both endpoint +configurations. diff --git a/tests/database/README_CN.md b/tests/database/README_CN.md index 80d38a50b..6a774b42e 100644 --- a/tests/database/README_CN.md +++ b/tests/database/README_CN.md @@ -148,3 +148,45 @@ bash scripts/test_databases.sh database-build redis 验证拥有型结果、取消、TLS 与正常停机。`ark_database_public_redis_plugin_tests` 和 `ark_database_public_mysql_plugin_tests` 加载实际隐藏符号动态库。 `check-database` 还编译迁移后的示例。MySQL 检查要求开启 SSL,真实服务目标仍需显式配置端点。 + +## 公开模块真实服务验收 + +`ark_database_public_service_tests` 通过公开的 `mysql_module`、`redis_module` +接口调用真实实现,使用已注册消费模块的 `task_group` 和管理器共享的 +`manual_runtime`。这是独立的 C++ 可执行程序;`check-database` 仅编译此程序,不运行,也不连接服务。 + +```sh +cmake --build database-build --target ark_database_public_service_tests --parallel 2 +# 先配置隔离服务的端点,再选择一种协议或同时运行两种协议。 +bash scripts/test_databases.sh database-build public-redis +bash scripts/test_databases.sh database-build public-mysql +bash scripts/test_databases.sh database-build public-services +# 也可以直接运行: +./database-build/tests/database/ark_database_public_service_tests redis +./database-build/tests/database/ark_database_public_service_tests mysql +``` + +Redis 要求设置 `ARK_DATABASE_REDIS_HOST` 和 `ARK_DATABASE_REDIS_PORT`。 +可选的 `ARK_DATABASE_REDIS_USER`、`ARK_DATABASE_REDIS_PASSWORD` 用于认证; +验证认证路径时,需使用启用了认证的临时服务。测试在注册后修改调用方配置, +验证有序的二进制 ECHO/ECHO/PING 流水线及后续命令,不执行写入。 + +MySQL 要求设置 `ARK_DATABASE_MYSQL_HOST`、`ARK_DATABASE_MYSQL_PORT`、 +`ARK_DATABASE_MYSQL_USER`、`ARK_DATABASE_MYSQL_PASSWORD`(必须存在,可以为空)、 +`ARK_DATABASE_MYSQL_DATABASE` 和 `ARK_DATABASE_MYSQL_CA`。连接必须使用 TLS 并验证证书。 +使用临时数据库,以及具备 CREATE、DROP、SELECT、INSERT 权限的账号。 +测试创建唯一的 `ark_public_service_...` InnoDB 表,执行文本查询、二进制预处理查询、 +事务查询和预处理插入,再验证提交数据可见、回滚数据不可见。流程结束后删除测试表; +断言失败且连接仍可用时,也会尝试删除。连接失败、活动事务清理或强制退出进程可能留下测试表, +需随临时数据库一起清理。 + +两种协议均检查协程在拥有线程及共享执行器上恢复、输入与配置的所有权,以及后续操作和 +模块/运行时销毁后结果仍然有效。管理器在已受理任务等待期间进入自然排空状态, +允许流程完成后续请求,再验证成功且可重复调用的停止操作。每种协议还会在 IO 等待期间主动 +触发宿主栈展开并重复整个流程:命名协程函数按值拥有输入,拥有者析构时排空流程,外层作用域 +再验证完成结果与保留的数据。这些真实服务用例不注入服务故障,也不测试取消。协议端点回归和动态库回归仍是独立检查;此处结果不代表动态库卸载、 +故障恢复或容量验收。 + +程序必须显式配置端点;配置缺失、无效或验收失败均返回非零状态,不打印凭据或任意原生错误文本, +也不会启动或停止服务。`all` 也运行这两种公开模块真实服务模式,因此要求启用 SSL, +并配置两种服务的端点。 diff --git a/tests/database/public_service.cpp b/tests/database/public_service.cpp new file mode 100644 index 000000000..d05e09b00 --- /dev/null +++ b/tests/database/public_service.cpp @@ -0,0 +1,407 @@ +// Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ark/redis/redis_module_impl.hpp" + +#if defined(ARKNET_ENABLE_SSL) +#include "ark/mysql/mysql_module_impl.hpp" +#endif + +namespace { + +using namespace std::chrono_literals; +unsigned checks{}; + +class acceptance_failure : public std::runtime_error +{ +public: + using std::runtime_error::runtime_error; +}; + +void require(bool condition, const std::string& message) { + ++checks; + if (!condition) + { + throw acceptance_failure(message); + } +} + +std::string environment(const char* name, bool allow_empty = false) { + const auto* value = std::getenv(name); + require(value && (allow_empty || *value), std::string("Required test environment variable: ") + name); + return value; +} + +std::uint16_t port(const char* name) { + const auto text = environment(name); + unsigned number{}; + const auto parsed = std::from_chars(text.data(), text.data() + text.size(), number); + require(parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size() && number > 0 && number <= 65535, + std::string(name) + " must be an integer between 1 and 65535"); + return static_cast(number); +} + +struct workflow_result +{ + std::exception_ptr failure; + unsigned completions{}; + bool completion_on_owner{}; +}; + +struct unwind_workflow +{ +}; + +void verify_workflow(const workflow_result& result) { + if (result.failure) + { + std::rethrow_exception(result.failure); + } + require(result.completions == 1 && result.completion_on_owner, + "Workflow completion was not delivered once on its owner"); +} + +// Register the real implementation exactly as a plugin does, with a consumer's +// manager-owned task group sharing the module's manual_runtime. +template +struct module_owner +{ + ark::plugin_manager manager; + module_type database; + ark::module consumer; + ark::task_group* tasks{}; + std::shared_ptr runtime; + const std::thread::id thread{std::this_thread::get_id()}; + workflow_result& result; + bool stopped{}; + + explicit module_owner(workflow_result& report) + : result(report) { + database.set_plugin_manager(&manager); + database.set_name("database"); + require(manager.add_module("database", &database) && manager.add_module("consumer", &consumer), + "Cannot install public database test modules"); + require(manager.add_update_module(&database), "Cannot install public database update hook"); + runtime = manager.get_task_runtime(); + tasks = &manager.create_task_group(consumer); + require(database.init(), "Cannot initialize public database module"); + } + + ~module_owner() { + if (!stopped) + { + // Never destroy business captures or a group with suspended tasks. + // Failure paths keep pumping accepted work before unwinding. + try + { finish(); } + catch (...) + { + std::cerr << "Public database cleanup could not drain\n" << std::flush; + std::_Exit(1); + } + } + } + + void check_executor() const { + require(std::this_thread::get_id() == thread && runtime->get_executor().running_in_this_thread(), + "Public database coroutine escaped its owner thread/shared manual runtime"); + } + + void start(ark::task<> workflow) { + require(tasks->start(std::move(workflow), + [this](std::exception_ptr error) { + result.completion_on_owner = + std::this_thread::get_id() == thread && runtime->get_executor().running_in_this_thread(); + result.failure = std::move(error); + ++result.completions; + }) != 0, + "Public database workflow was not accepted"); + require(manager.update(0), "Cannot start public database workflow"); + require(tasks->pending() == 1, "Public database workflow did not suspend for native IO"); + require(!manager.stop(0), "Manager stopped before its accepted database workflow drained"); + require(manager.get_shutdown_state() == ark::plugin_manager::shutdown_state::draining, + "Manager did not enter natural drain"); + require(!database.configure("late", {}), "Database admitted configuration during drain"); + } + + void finish() { + const auto deadline = std::chrono::steady_clock::now() + 30s; + while (!(stopped = manager.stop(0))) + { + if (manager.get_shutdown_state() == ark::plugin_manager::shutdown_state::failed || + std::chrono::steady_clock::now() >= deadline) + { + throw acceptance_failure("Public database manager did not drain/stop successfully"); + } + require(manager.update(0), "Public database manager update failed during drain"); + std::this_thread::sleep_for(1ms); + } + } + + void verify() { + finish(); + verify_workflow(result); + require(tasks->pending() == 0 && tasks->closed(), "Stopped manager retained public database tasks"); + require(manager.stop(0), "Manager stop was not idempotent"); + } +}; + +ark::task<> redis_workflow(module_owner& owner, ark::task pending, + std::string binary, std::optional& retained) { + ark::redis_module& database = owner.database; + retained = co_await std::move(pending); + owner.check_executor(); + require(bool(*retained) && retained->replies.size() == 3, "Public Redis binary pipeline failed"); + require( + retained->replies[0].text == binary && retained->replies[1].text.empty() && retained->replies[2].text == "PONG", + "Public Redis lost owning inputs or ordered binary replies"); + auto second = co_await database.command("primary", {"PING"}, 5s); + owner.check_executor(); + require(bool(second) && second.replies.size() == 1 && second.replies[0].text == "PONG", + "Public Redis follow-up command failed during natural drain"); + require(retained->replies[0].text == binary, "Public Redis prior result changed after the next command"); +} + +void check_redis(bool unwind) { + ark::redis_options options; + options.pool.connection.host = environment("ARK_DATABASE_REDIS_HOST"); + options.pool.connection.port = std::to_string(port("ARK_DATABASE_REDIS_PORT")); + if (const auto* user = std::getenv("ARK_DATABASE_REDIS_USER")) + { + options.pool.connection.username = user; + } + if (const auto* password = std::getenv("ARK_DATABASE_REDIS_PASSWORD")) + { + options.pool.connection.password = password; + } + options.pool.max_connections = 1; + options.pool.client_configuration.connect_timeout = 3s; + const std::string binary("public\0redis\r\nvalue", 19); + std::optional retained; + workflow_result report; + bool unwound{}; + try + { + module_owner owner(report); + ark::redis_module& database = owner.database; + require(database.configure("primary", options), "Cannot configure public Redis module"); + // The module owns endpoint and authentication strings after configure(). + options.pool.connection.host = "invalid.invalid"; + options.pool.connection.username = "changed"; + options.pool.connection.password = "changed"; + std::vector commands{{"ECHO", binary}, {"ECHO", ""}, {"PING"}}; + auto pending = database.pipeline("primary", commands, 5s); + commands[0][1] = "changed"; + commands.clear(); + owner.start(redis_workflow(owner, std::move(pending), binary, retained)); + if (unwind) + { + throw unwind_workflow{}; + } + owner.verify(); + } + catch (const unwind_workflow&) + { unwound = true; } + require(unwound == unwind, "Redis host stack did not unwind as requested"); + verify_workflow(report); + require(retained && retained->replies[0].text == binary, + "Public Redis owning result was lost after module/runtime destruction"); +} + +#if defined(ARKNET_ENABLE_SSL) +void success(const ark::mysql_reply& reply) { + require(bool(reply), "Public MySQL operation failed (code " + + std::to_string(static_cast(reply.result.status.code)) + ", server code " + + std::to_string(reply.result.status.server_code) + ")"); + require(!reply.statement_close_exception && (!reply.statement_close || bool(*reply.statement_close)), + "Public MySQL statement cleanup failed"); +} + +void success(const ark::mysql_transaction_reply& reply) { + require(bool(reply), "Public MySQL transaction operation failed (code " + + std::to_string(static_cast(reply.result.status.code)) + ", server code " + + std::to_string(reply.result.status.server_code) + ")"); + require(!reply.statement_close_exception && (!reply.statement_close || bool(*reply.statement_close)) && + !reply.transaction_status.cleanup_exception && !reply.transaction_status.lease_return_exception, + "Public MySQL transaction cleanup failed"); +} + +ark::task<> mysql_checks(module_owner& owner, ark::task prepared, + std::string table, std::string binary, std::optional& retained_text, + std::optional& retained_prepared) { + ark::mysql_module& database = owner.database; + retained_text = co_await database.query("primary", "SELECT -7 AS repeated, 'owned text' AS repeated, NULL", 5s); + owner.check_executor(); + success(*retained_text); + const auto& text = retained_text->result; + require(text.columns.size() == 3 && text.rows.size() == 1 && text.rows[0].size() == 3 && + text.columns[0].name == "repeated" && text.columns[1].name == "repeated", + "Public MySQL query lost shape or duplicate column names"); + require(std::get(text.rows[0][0]) == -7 && std::get(text.rows[0][1]) == "owned text" && + std::holds_alternative(text.rows[0][2]), + "Public MySQL query lost owning typed fields"); + retained_prepared = co_await std::move(prepared); + owner.check_executor(); + success(*retained_prepared); + const auto& result = retained_prepared->result; + require(result.rows.size() == 1 && result.rows[0].size() == 2 && std::get(result.rows[0][0]) == -9 && + std::get(result.rows[0][1]) == binary, + "Public MySQL prepared query lost owning inputs/binary results"); + require(retained_prepared->statement_close && bool(*retained_prepared->statement_close), + "Public MySQL prepared statement close was not reported"); + for (const bool commit : {true, false}) + { + auto begun = co_await database.begin("primary", {10s, 3s}); + owner.check_executor(); + success(begun); + require(begun.transaction && begun.transaction_status.outcome == ark::mysql_transaction_outcome::active, + "Public MySQL transaction did not begin"); + auto queried = co_await database.query(begun.transaction, "SELECT CONNECTION_ID()"); + owner.check_executor(); + success(queried); + require(queried.result.rows.size() == 1, "Public MySQL transaction query failed"); + std::vector values{std::int64_t{commit ? 1 : 2}, binary}; + auto inserted = co_await database.execute( + begun.transaction, "INSERT INTO " + table + " (id, payload) VALUES (?, ?)", std::move(values)); + owner.check_executor(); + success(inserted); + require(inserted.result.affected_rows == 1 && inserted.statement_close && bool(*inserted.statement_close), + "Public MySQL transaction prepared insert/cleanup failed"); + auto ended = + commit ? co_await database.commit(begun.transaction) : co_await database.rollback(begun.transaction); + owner.check_executor(); + success(ended); + require(ended.transaction_status.outcome == + (commit ? ark::mysql_transaction_outcome::committed : ark::mysql_transaction_outcome::rolled_back), + "Public MySQL commit/rollback terminal outcome changed"); + } + auto persisted = co_await database.query("primary", "SELECT id, payload FROM " + table + " ORDER BY id", 5s); + owner.check_executor(); + success(persisted); + require(persisted.result.rows.size() == 1 && persisted.result.rows[0].size() == 2 && + std::get(persisted.result.rows[0][0]) == 1 && + std::get(persisted.result.rows[0][1]) == binary, + "Public MySQL commit/rollback visibility or binary persistence failed"); + require(std::get(retained_text->result.rows[0][1]) == "owned text" && + std::get(retained_prepared->result.rows[0][1]) == binary, + "Public MySQL prior results changed after transaction reuse"); +} + +ark::task<> mysql_workflow(module_owner& owner, ark::task prepared, + std::string table, std::string binary, std::optional& retained_text, + std::optional& retained_prepared) { + ark::mysql_module& database = owner.database; + auto created = co_await database.query( + "primary", "CREATE TABLE " + table + " (id BIGINT PRIMARY KEY, payload VARBINARY(128)) ENGINE=InnoDB", 5s); + owner.check_executor(); + success(created); + std::exception_ptr error; + try + { co_await mysql_checks(owner, std::move(prepared), table, std::move(binary), retained_text, retained_prepared); } + catch (...) + { error = std::current_exception(); } + auto removed = co_await database.query("primary", "DROP TABLE " + table, 5s); + owner.check_executor(); + success(removed); + if (error) + { + std::rethrow_exception(error); + } +} + +void check_mysql(bool unwind) { + ark::mysql_options options; + options.pool.connection.host = environment("ARK_DATABASE_MYSQL_HOST"); + options.pool.connection.port = port("ARK_DATABASE_MYSQL_PORT"); + options.pool.connection.username = environment("ARK_DATABASE_MYSQL_USER"); + options.pool.connection.password = environment("ARK_DATABASE_MYSQL_PASSWORD", true); + options.pool.connection.database = environment("ARK_DATABASE_MYSQL_DATABASE"); + options.pool.connection.ca_file = environment("ARK_DATABASE_MYSQL_CA"); + options.pool.connection.tls = arknet::mysql::tls_mode::required; + options.pool.connection.timeout = 5s; + options.pool.max_connections = 1; + const std::string binary("public\0mysql\r\nvalue", 19); + const auto table = + "ark_public_service_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()); + std::optional retained_text, retained_prepared; + workflow_result report; + bool unwound{}; + try + { + module_owner owner(report); + ark::mysql_module& database = owner.database; + require(database.configure("primary", options), "Cannot configure public MySQL module"); + options.pool.connection.host = "invalid.invalid"; + options.pool.connection.username = "changed"; + options.pool.connection.password = "changed"; + options.pool.connection.ca_file = "missing-certificate.pem"; + std::vector parameters{std::int64_t{-9}, binary}; + auto prepared = database.execute("primary", "SELECT CAST(? AS SIGNED), ?", parameters, 5s); + parameters[0] = std::int64_t{123}; + parameters[1] = std::string("changed"); + owner.start(mysql_workflow(owner, std::move(prepared), table, binary, retained_text, retained_prepared)); + if (unwind) + { + throw unwind_workflow{}; + } + owner.verify(); + } + catch (const unwind_workflow&) + { unwound = true; } + require(unwound == unwind, "MySQL host stack did not unwind as requested"); + verify_workflow(report); + require(retained_text && std::get(retained_text->result.rows[0][1]) == "owned text" && + retained_prepared && std::get(retained_prepared->result.rows[0][1]) == binary, + "Public MySQL owning results were lost after module/runtime destruction"); +} +#endif + +} // namespace + +int main(int argc, char** argv) { + try + { + require(argc == 2, "Usage: ark_database_public_service_tests redis|mysql"); + const std::string_view protocol(argv[1]); + if (protocol == "redis") + { + check_redis(false); + check_redis(true); + } +#if defined(ARKNET_ENABLE_SSL) + else if (protocol == "mysql") + { + check_mysql(false); + check_mysql(true); + } +#endif + else + { + throw acceptance_failure("Requested public database protocol is not enabled"); + } + std::cout << protocol << " public module real-service checks passed: " << checks << '\n'; + return 0; + } + catch (const acceptance_failure& error) + { std::cerr << error.what() << '\n'; } + catch (...) + { + // Native diagnostics can contain server-controlled text. Do not emit + // endpoint/authentication configuration or arbitrary exception messages. + std::cerr << "Public database service acceptance raised an unexpected exception\n"; + } + return 1; +} From e98633407b6544ff3f18fbdc7346efa728e4ac4d Mon Sep 17 00:00:00 2001 From: nick Date: Tue, 20 Oct 2026 09:48:21 +0800 Subject: [PATCH 2/3] test(database): verify public service errors and deadlines --- scripts/test_databases.sh | 10 +- tests/database/CMakeLists.txt | 12 +- tests/database/README.md | 48 ++ tests/database/README_CN.md | 37 ++ tests/database/public_failure_service.cpp | 515 ++++++++++++++++++++++ 5 files changed, 618 insertions(+), 4 deletions(-) create mode 100644 tests/database/public_failure_service.cpp diff --git a/scripts/test_databases.sh b/scripts/test_databases.sh index a0cae393d..e8dd1d1c2 100755 --- a/scripts/test_databases.sh +++ b/scripts/test_databases.sh @@ -3,14 +3,14 @@ set -euo pipefail if [[ $# -lt 1 || $# -gt 2 ]]; then - printf 'Usage: %s BUILD_DIR [local|redis|mysql|redis-awaitable|mysql-awaitable|public-redis|public-mysql|public-services|all]\n' "$0" >&2 + printf 'Usage: %s BUILD_DIR [local|redis|mysql|redis-awaitable|mysql-awaitable|public-redis|public-mysql|public-services|public-redis-failures|public-mysql-failures|all]\n' "$0" >&2 exit 2 fi database_test_dir=$(cd -- "$1" && pwd)/tests/database database_test_mode=${2:-local} case "$database_test_mode" in - local|redis|mysql|redis-awaitable|mysql-awaitable|public-redis|public-mysql|public-services|all) ;; + local|redis|mysql|redis-awaitable|mysql-awaitable|public-redis|public-mysql|public-services|public-redis-failures|public-mysql-failures|all) ;; *) printf 'Unknown database test mode: %s\n' "$database_test_mode" >&2; exit 2 ;; esac @@ -56,3 +56,9 @@ fi if [[ "$database_test_mode" == public-mysql || "$database_test_mode" == public-services || "$database_test_mode" == all ]]; then run_database_test ark_database_public_service_tests mysql fi +if [[ "$database_test_mode" == public-redis-failures ]]; then + run_database_test ark_database_public_failure_service_tests redis +fi +if [[ "$database_test_mode" == public-mysql-failures ]]; then + run_database_test ark_database_public_failure_service_tests mysql +fi diff --git a/tests/database/CMakeLists.txt b/tests/database/CMakeLists.txt index c29f11d48..74e0350be 100644 --- a/tests/database/CMakeLists.txt +++ b/tests/database/CMakeLists.txt @@ -36,10 +36,13 @@ target_link_libraries(ark_database_redis_stop_tests PRIVATE arknet::arknet) ark_database_test(ark_database_public_redis_tests main.cpp public_redis_module.cpp "${ROOT_DIR}/src/plugins/redis/redis_module_impl.cpp") target_link_libraries(ark_database_public_redis_tests PRIVATE ark::core arknet::arknet) -# Explicit real-service acceptance; deliberately excluded from check-database. +# Real-service programs are compiled by check-database, but never run there. ark_database_test(ark_database_public_service_tests public_service.cpp "${ROOT_DIR}/src/plugins/redis/redis_module_impl.cpp") target_link_libraries(ark_database_public_service_tests PRIVATE ark::core arknet::arknet) +ark_database_test(ark_database_public_failure_service_tests public_failure_service.cpp + "${ROOT_DIR}/src/plugins/redis/redis_module_impl.cpp") +target_link_libraries(ark_database_public_failure_service_tests PRIVATE ark::core arknet::arknet) add_library(ark_database_redis_plugin SHARED "${ROOT_DIR}/src/plugins/redis/redis_plugin.cpp" "${ROOT_DIR}/src/plugins/redis/redis_module_impl.cpp") target_include_directories(ark_database_redis_plugin PRIVATE "${ROOT_DIR}/src/plugins") @@ -72,9 +75,14 @@ add_custom_target(check-database USES_TERMINAL) # Compile real-service acceptance in ordinary CI without running any server work. -add_dependencies(check-database ark_database_redis_sample_check ark_database_public_service_tests) +add_dependencies(check-database ark_database_redis_sample_check ark_database_public_service_tests + ark_database_public_failure_service_tests) if(ARKNET_ENABLE_SSL) + target_sources(ark_database_public_failure_service_tests PRIVATE + "${ROOT_DIR}/src/plugins/mysql/mysql_module_impl.cpp") + target_compile_definitions(ark_database_public_failure_service_tests PRIVATE + ARK_DATABASE_UNTRUSTED_CA="${ARKNET_SOURCE_DIR}/tests/certs/wrong.pem") target_sources(ark_database_public_service_tests PRIVATE "${ROOT_DIR}/src/plugins/mysql/mysql_module_impl.cpp") target_compile_definitions(ark_database_public_redis_tests PRIVATE diff --git a/tests/database/README.md b/tests/database/README.md index 6a0044fef..e5cd6de9b 100644 --- a/tests/database/README.md +++ b/tests/database/README.md @@ -238,3 +238,51 @@ configuration or failure, and does not print credentials or arbitrary native err messages. It never starts or stops servers. `all` also includes both public real-service modes and therefore requires an SSL-enabled build and both endpoint configurations. + +## Public real-service failure checks + +The separate `ark_database_public_failure_service_tests` executable checks wrong +Redis/MySQL passwords, an untrusted MySQL CA, and real slow-command deadlines. +`check-database` compiles it without running it. Choose a failure mode explicitly; +these modes are not included in `all`. + +```sh +cmake --build database-build --target ark_database_public_failure_service_tests --parallel 2 +bash scripts/test_databases.sh database-build public-redis-failures +bash scripts/test_databases.sh database-build public-mysql-failures +# Or invoke the executable with redis or mysql. +``` + +Use the same isolated endpoint variables as the public service checks above. +The Redis failure mode additionally requires a nonempty +`ARK_DATABASE_REDIS_PASSWORD` and permission for `CLIENT LIST`. MySQL requires SSL, +a trusted `ARK_DATABASE_MYSQL_CA` and access to the current account's sessions in +`information_schema.processlist`; no PROCESS privilege or extra schema is needed. +The unrelated CA fixture is `ARKNET_SOURCE_DIR/tests/certs/wrong.pem`, already used +by the local TLS regressions. No new environment variable or service management is +introduced. Missing configuration, observer permission errors, or a requested +protocol unavailable in the build fail with nonzero status. + +Credential/TLS failures must retain their native error and known-unsent business +command state. Subsequent explicit requests use the valid configuration. The +warmed primary then runs BLPOP on an absent unique `ark_failure_...` key (30-second +server block) or a tagged `SELECT SLEEP(10)`, with a two-second client deadline. +BLPOP never receives producer data; the test first checks that its key is absent. +Both deadlines must be delivered within five seconds. + +An independently configured connection observes Redis's named blocked client via +`CLIENT LIST`, or MySQL's tagged sleeping session via `processlist`. Only this +server-visible observation establishes that the request was sent; polling timers +only throttle observations. The timeout must retain its unknown-execution flag. +An explicit new PING/SELECT must recover through the same public connection name. +The observer rejects a second slow client/session, then checks for 500 ms after +the original disappears. This bounds the no-replay evidence to these tagged +slow requests during the observed recovery window; it does not prove write-replay +behavior or absence of later activity outside the window. + +All coroutines and task completions must stay on the owner thread/shared manual +runtime. Accepted slow/observer tasks finish during natural manager drain before +native retirement and idempotent stop. These cases do not test cancellation, +server termination, dynamic-library unloading or capacity. Slow-query results +remain owned after module destruction. Native/server error text and credentials +are not printed. diff --git a/tests/database/README_CN.md b/tests/database/README_CN.md index 6a774b42e..518e75a17 100644 --- a/tests/database/README_CN.md +++ b/tests/database/README_CN.md @@ -190,3 +190,40 @@ MySQL 要求设置 `ARK_DATABASE_MYSQL_HOST`、`ARK_DATABASE_MYSQL_PORT`、 程序必须显式配置端点;配置缺失、无效或验收失败均返回非零状态,不打印凭据或任意原生错误文本, 也不会启动或停止服务。`all` 也运行这两种公开模块真实服务模式,因此要求启用 SSL, 并配置两种服务的端点。 + +## 公开模块真实服务负面验收 + +独立的 `ark_database_public_failure_service_tests` 验证 Redis/MySQL 错误密码、 +MySQL 不可信 CA,以及真实慢命令的截止时间。`check-database` 仅编译该程序,不运行。 +需显式选择负面验收模式;`all` 不包含这些模式。 + +```sh +cmake --build database-build --target ark_database_public_failure_service_tests --parallel 2 +bash scripts/test_databases.sh database-build public-redis-failures +bash scripts/test_databases.sh database-build public-mysql-failures +# 也可以直接运行程序,参数为 redis 或 mysql。 +``` + +沿用上方公开服务验收的隔离端点环境变量。Redis 负面模式额外要求非空的 +`ARK_DATABASE_REDIS_PASSWORD`,账号需有 `CLIENT LIST` 权限。MySQL 要求开启 SSL、 +使用可信的 `ARK_DATABASE_MYSQL_CA`,且账号能通过 `information_schema.processlist` +查看自身会话;不需要 PROCESS 权限或额外数据库。不可信 CA 使用本地 TLS 回归已有的 +`ARKNET_SOURCE_DIR/tests/certs/wrong.pem`,不引入新的环境变量或服务管理操作。 +配置缺失、观察连接权限不足或构建中没有请求的协议时,程序返回非零状态。 + +认证/TLS 失败必须保留原生错误,以及业务命令尚未发送的已知状态。随后使用正确配置显式发起 +新请求。预热后的主连接对不存在的唯一 `ark_failure_...` key 执行 BLPOP(服务端阻塞 +30 秒),或执行带唯一注释的 `SELECT SLEEP(10)`,客户端截止时间均为 2 秒。 +测试先检查 Redis key 不存在,且不会向该 key 写入数据。两种超时结果均须在 5 秒内交付。 + +独立配置的观察连接通过 Redis `CLIENT LIST` 确认具名客户端正在阻塞,或通过 MySQL +`processlist` 确认带唯一注释的会话正在休眠。只有服务端可见状态用于确认请求已经发送; +轮询定时器仅控制观察频率。超时结果必须保留执行状态未知标志。随后通过同一公开连接名显式 +执行 PING/SELECT,并验证恢复成功。观察连接拒绝出现第二个慢客户端或会话,且在原请求消失后 +继续检查 500 毫秒。该证据仅覆盖观察窗口内的慢查询恢复,不证明写操作的重放行为,也不证明 +窗口以外没有后续活动。 + +所有协程及任务完成回调均须在拥有线程及共享手动运行时上执行。管理器进入自然排空状态后, +已受理的慢请求和观察任务继续执行,完成后才退出原生资源,并验证停止操作可重复调用。 +这些用例不测试取消、服务端终止、动态库卸载或容量。模块销毁后,慢查询结果仍须有效。 +程序不打印凭据、原生错误文本或服务端错误文本。 diff --git a/tests/database/public_failure_service.cpp b/tests/database/public_failure_service.cpp new file mode 100644 index 000000000..a8bc4e96a --- /dev/null +++ b/tests/database/public_failure_service.cpp @@ -0,0 +1,515 @@ +// Copyright (c) 2026 OpenArkStudio. Licensed under Apache-2.0. See LICENSE. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ark/redis/redis_module_impl.hpp" + +#if defined(ARKNET_ENABLE_SSL) +#include "ark/mysql/mysql_module_impl.hpp" +#endif + +namespace { + +using namespace std::chrono_literals; +unsigned checks{}; + +class acceptance_failure : public std::runtime_error +{ +public: + using std::runtime_error::runtime_error; +}; + +void require(bool condition, const std::string& message) { + ++checks; + if (!condition) + { + throw acceptance_failure(message); + } +} + +std::string environment(const char* name, bool allow_empty = false) { + const auto* value = std::getenv(name); + require(value && (allow_empty || *value), std::string("Required test environment variable: ") + name); + return value; +} + +std::uint16_t port(const char* name) { + const auto text = environment(name); + unsigned number{}; + const auto parsed = std::from_chars(text.data(), text.data() + text.size(), number); + require(parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size() && number > 0 && number <= 65535, + std::string(name) + " must be an integer between 1 and 65535"); + return static_cast(number); +} + +// This timer throttles observer polling; server-visible state, never a guessed +// delay, establishes whether the slow command reached the server. +ark::task<> observer_interval() { + asio::steady_timer timer(co_await asio::this_coro::executor); + timer.expires_after(20ms); + co_await timer.async_wait(asio::use_awaitable); +} + +template +struct module_owner +{ + ark::plugin_manager manager; + module_type database; + ark::module consumer; + ark::task_group* tasks{}; + std::shared_ptr runtime; + const std::thread::id thread{std::this_thread::get_id()}; + std::exception_ptr failure; + unsigned completions{}; + bool completions_on_owner{true}; + bool stopped{}; + + module_owner() { + database.set_plugin_manager(&manager); + database.set_name("database"); + require(manager.add_module("database", &database) && manager.add_module("consumer", &consumer), + "Cannot install public database failure test modules"); + require(manager.add_update_module(&database), "Cannot install public database update hook"); + runtime = manager.get_task_runtime(); + tasks = &manager.create_task_group(consumer); + require(database.init(), "Cannot initialize public database module"); + } + + ~module_owner() { + if (!stopped) + { + try + { finish(); } + catch (...) + { + std::cerr << "Public database failure test could not drain\n" << std::flush; + std::_Exit(1); + } + } + } + + void check_executor() const { + require(std::this_thread::get_id() == thread && runtime->get_executor().running_in_this_thread(), + "Failure coroutine escaped its owner thread/shared manual runtime"); + } + + void start(ark::task<> workflow) { + require(tasks->start(std::move(workflow), + [this](std::exception_ptr error) { + completions_on_owner = completions_on_owner && std::this_thread::get_id() == thread && + runtime->get_executor().running_in_this_thread(); + if (error && !failure) + { + failure = std::move(error); + } + ++completions; + }) != 0, + "Failure workflow was not accepted"); + } + + void check_failure() const { + if (failure) + { + std::rethrow_exception(failure); + } + } + + void preflight(ark::task<> workflow) { + start(std::move(workflow)); + const auto deadline = std::chrono::steady_clock::now() + 20s; + while (tasks->pending()) + { + require(std::chrono::steady_clock::now() < deadline, "Failure preflight did not complete"); + require(manager.update(0), "Failure preflight update failed"); + std::this_thread::sleep_for(1ms); + } + check_failure(); + require(completions == 1 && completions_on_owner, "Preflight completion escaped its owner or repeated"); + } + + void finish() { + const auto deadline = std::chrono::steady_clock::now() + 30s; + while (!(stopped = manager.stop(0))) + { + require(manager.get_shutdown_state() != ark::plugin_manager::shutdown_state::failed && + std::chrono::steady_clock::now() < deadline, + "Failure workflows did not naturally drain/retire native resources"); + require(manager.update(0), "Failure workflow update failed during drain"); + std::this_thread::sleep_for(1ms); + } + } + + void verify() { + require(tasks->pending() == 2, "Slow request and independent observer were not both admitted"); + require(!manager.stop(0), "Manager stopped before failure workflows drained"); + require(manager.get_shutdown_state() == ark::plugin_manager::shutdown_state::draining, + "Manager did not begin natural drain"); + finish(); + check_failure(); + require(completions == 3 && completions_on_owner, "Failure completions repeated or escaped their owner"); + require(tasks->closed() && tasks->pending() == 0, "Manager stopped with pending failure tasks"); + require(manager.stop(0), "Manager stop was not idempotent after failures"); + } +}; + +using redis_owner = module_owner; + +void redis_success(const arknet::redis::result& result) { + require(bool(result) && result.replies.size() == 1, "Explicit Redis recovery/observer request failed"); +} + +ark::task<> redis_preflight(redis_owner& owner, std::string key) { + ark::redis_module& database = owner.database; + auto rejected = co_await database.command("wrong-password", {"PING"}, 5s); + owner.check_executor(); + require(rejected.error == arknet::redis::client_errc::handshake_failed && !rejected.command_may_have_run, + "Wrong Redis password lost its known-unsent handshake failure"); + require(!rejected.replies.empty() && rejected.replies[0].type == arknet::redis::value_type::error, + "Wrong Redis password lost the native server error reply"); + auto recovered = co_await database.command("primary", {"PING"}, 5s); + owner.check_executor(); + redis_success(recovered); + require(recovered.replies[0].text == "PONG", "Explicit Redis request did not recover with valid credentials"); + auto absent = co_await database.command("primary", {"EXISTS", std::move(key)}, 5s); + owner.check_executor(); + redis_success(absent); + require(absent.replies[0].type == arknet::redis::value_type::integer && absent.replies[0].integer == 0, + "Redis slow-test key already exists; refusing a destructive pop"); + auto observer = co_await database.command("observer", {"PING"}, 5s); + owner.check_executor(); + redis_success(observer); + require(observer.replies[0].text == "PONG", "Redis observer could not authenticate"); +} + +struct redis_slow_state +{ + std::optional result; + bool observed{}; + bool recovered{}; +}; + +ark::task<> redis_slow(redis_owner& owner, redis_slow_state& state, std::string key) { + // An absent unique key and no producer keep the server blocked for 30 seconds. + // The independently warmed client deadline is two seconds. + const auto started = std::chrono::steady_clock::now(); + state.result = co_await owner.database.command("primary", {"BLPOP", std::move(key), "30"}, 2s); + owner.check_executor(); + require(std::chrono::steady_clock::now() - started < 5s, + "Redis client deadline was not delivered well before its server block timeout"); +} + +std::string client_field(std::string_view line, std::string_view name) { + const auto marker = std::string(name) + "="; + for (std::size_t position{}; position < line.size();) + { + const auto end = line.find(' ', position); + const auto field = line.substr(position, end == std::string_view::npos ? end : end - position); + if (field.starts_with(marker)) + { + return std::string(field.substr(marker.size())); + } + if (end == std::string_view::npos) + { + break; + } + position = end + 1; + } + return {}; +} + +std::optional blocked_redis_client(const arknet::redis::result& listing, const std::string& tag) { + redis_success(listing); + const auto& reply = listing.replies[0]; + require(reply.type == arknet::redis::value_type::bulk_string || + reply.type == arknet::redis::value_type::verbatim_string, + "Redis CLIENT LIST did not return a bulk/verbatim string"); + std::string_view remaining = reply.text; + if (reply.type == arknet::redis::value_type::verbatim_string) + { + // Native RESP3 results retain their format tag. Parse a view without + // altering the owning result or accepting unrelated format encodings. + require(remaining.starts_with("txt:"), "Redis CLIENT LIST returned a non-text verbatim string"); + remaining.remove_prefix(4); + } + std::optional blocked; + while (!remaining.empty()) + { + const auto newline = remaining.find('\n'); + const auto line = remaining.substr(0, newline); + if (client_field(line, "name") == tag && client_field(line, "cmd") == "blpop" && + client_field(line, "flags").find('b') != std::string::npos) + { + require(!blocked, "Redis observer saw concurrent replay of the uniquely named BLPOP"); + blocked = client_field(line, "id"); + require(!blocked->empty(), "Redis observer could not identify its blocked client"); + } + if (newline == std::string_view::npos) + { + break; + } + remaining.remove_prefix(newline + 1); + } + return blocked; +} + +ark::task<> redis_observe(redis_owner& owner, redis_slow_state& state, std::string tag) { + std::optional original; + std::optional quiet_until; + const auto deadline = std::chrono::steady_clock::now() + 12s; + for (;;) + { + owner.check_failure(); + require(std::chrono::steady_clock::now() < deadline, "Redis observer did not see deadline and recovery"); + auto listing = co_await owner.database.command("observer", {"CLIENT", "LIST"}, 3s); + owner.check_executor(); + const auto blocked = blocked_redis_client(listing, tag); + if (blocked) + { + require(!quiet_until, "Redis observer saw a new blocked command after the original drained"); + require(!original || *original == *blocked, "Redis observer saw BLPOP replayed on a new connection"); + original = *blocked; + state.observed = true; + } + if (state.result && !state.recovered) + { + require(state.observed, "Redis deadline completed before server-visible BLPOP observation"); + require(state.result->error == arknet::redis::client_errc::timeout && state.result->command_may_have_run, + "Sent Redis deadline lost its timeout/unknown execution state"); + auto recovered = co_await owner.database.command("primary", {"PING"}, 5s); + owner.check_executor(); + redis_success(recovered); + require(recovered.replies[0].text == "PONG", "Explicit new Redis request did not recover after deadline"); + state.recovered = true; + } + if (!blocked && state.recovered) + { + if (!quiet_until) + { + quiet_until = std::chrono::steady_clock::now() + 500ms; + } + if (std::chrono::steady_clock::now() >= *quiet_until) + { + break; + } + } + co_await observer_interval(); + owner.check_executor(); + } +} + +void check_redis() { + ark::redis_options options; + options.pool.connection.host = environment("ARK_DATABASE_REDIS_HOST"); + options.pool.connection.port = std::to_string(port("ARK_DATABASE_REDIS_PORT")); + options.pool.connection.password = environment("ARK_DATABASE_REDIS_PASSWORD"); + if (const auto* user = std::getenv("ARK_DATABASE_REDIS_USER")) + { + options.pool.connection.username = user; + } + options.pool.max_connections = 1; + options.pool.client_configuration.connect_timeout = 3s; + options.pool.client_configuration.drain_timeout = 100ms; + const auto tag = "ark_failure_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()); + options.pool.connection.client_name = tag; + redis_slow_state state; + { + redis_owner owner; + require(owner.database.configure("primary", options), "Cannot configure Redis primary"); + auto wrong_password = options; + wrong_password.pool.connection.password += "-ark-rejected-" + tag; + require(owner.database.configure("wrong-password", std::move(wrong_password)), + "Cannot configure rejected Redis credentials"); + options.pool.connection.client_name = tag + "_observer"; + require(owner.database.configure("observer", options), "Cannot configure Redis observer"); + owner.preflight(redis_preflight(owner, tag)); + owner.start(redis_slow(owner, state, tag)); + owner.start(redis_observe(owner, state, tag)); + owner.verify(); + } + require(state.observed && state.recovered && state.result && state.result->command_may_have_run, + "Redis deadline result/lifecycle evidence did not survive module destruction"); +} + +#if defined(ARKNET_ENABLE_SSL) +using mysql_owner = module_owner; + +void mysql_success(const ark::mysql_reply& reply) { + require(bool(reply), "Explicit MySQL recovery/observer request failed (code " + + std::to_string(static_cast(reply.result.status.code)) + ", server code " + + std::to_string(reply.result.status.server_code) + ")"); +} + +ark::task<> mysql_preflight(mysql_owner& owner) { + ark::mysql_module& database = owner.database; + auto rejected = co_await database.query("wrong-password", "SELECT 1", 5s); + owner.check_executor(); + require(rejected.result.status.code == arknet::mysql::connection_errc::authentication && + rejected.result.status.server_code == 1045 && rejected.result.status.sql_state == "28000" && + !rejected.result.status.outcome_unknown && rejected.result.rows.empty(), + "Wrong MySQL password lost its known-unsent authentication error"); + auto recovered = co_await database.query("primary", "SELECT 7", 5s); + owner.check_executor(); + mysql_success(recovered); + require(recovered.result.rows.size() == 1 && std::get(recovered.result.rows[0][0]) == 7, + "Explicit MySQL request did not recover with valid credentials"); + auto untrusted = co_await database.query("untrusted-ca", "SELECT 1", 5s); + owner.check_executor(); + require(untrusted.result.status.code == arknet::mysql::connection_errc::tls && + !untrusted.result.status.outcome_unknown && untrusted.result.rows.empty(), + "Untrusted MySQL CA lost its known-unsent TLS error"); + auto verified = co_await database.query("primary", "SELECT 8", 5s); + owner.check_executor(); + mysql_success(verified); + require(verified.result.rows.size() == 1 && std::get(verified.result.rows[0][0]) == 8, + "Explicit MySQL request did not recover with a trusted CA"); + auto observer = co_await database.query("observer", "SELECT 1", 5s); + owner.check_executor(); + mysql_success(observer); +} + +struct mysql_slow_state +{ + std::optional result; + bool observed{}; + bool recovered{}; +}; + +ark::task<> mysql_slow(mysql_owner& owner, mysql_slow_state& state, std::string tag) { + const auto started = std::chrono::steady_clock::now(); + state.result = co_await owner.database.query("primary", "SELECT SLEEP(10) /*" + tag + "*/", 2s); + owner.check_executor(); + require(std::chrono::steady_clock::now() - started < 5s, + "MySQL client deadline was not delivered well before its server sleep ended"); +} + +ark::task<> mysql_observe(mysql_owner& owner, mysql_slow_state& state, std::string tag) { + std::optional original; + std::optional quiet_until; + const auto deadline = std::chrono::steady_clock::now() + 18s; + const auto sql = "SELECT CAST(ID AS SIGNED) FROM information_schema.processlist " + "WHERE ID <> CONNECTION_ID() AND STATE = 'User sleep' AND INFO LIKE '%" + + tag + "%'"; + for (;;) + { + owner.check_failure(); + require(std::chrono::steady_clock::now() < deadline, "MySQL observer did not see deadline and recovery"); + auto listing = co_await owner.database.query("observer", sql, 3s); + owner.check_executor(); + mysql_success(listing); + require(listing.result.rows.size() <= 1, "MySQL observer saw concurrent replay of its tagged SLEEP"); + if (!listing.result.rows.empty()) + { + require(!quiet_until, "MySQL observer saw a new SLEEP after the original drained"); + const auto id = std::get(listing.result.rows[0][0]); + require(!original || *original == id, "MySQL observer saw SLEEP replayed on a new connection"); + original = id; + state.observed = true; + } + if (state.result && !state.recovered) + { + require(state.observed, "MySQL deadline completed before server-visible SLEEP observation"); + require(state.result->result.status.code == arknet::mysql::connection_errc::timeout && + state.result->result.status.outcome_unknown, + "Sent MySQL deadline lost its timeout/unknown execution state"); + auto recovered = co_await owner.database.query("primary", "SELECT 9", 5s); + owner.check_executor(); + mysql_success(recovered); + require(recovered.result.rows.size() == 1 && std::get(recovered.result.rows[0][0]) == 9, + "Explicit new MySQL request did not recover after deadline"); + state.recovered = true; + } + if (listing.result.rows.empty() && state.recovered) + { + if (!quiet_until) + { + quiet_until = std::chrono::steady_clock::now() + 500ms; + } + if (std::chrono::steady_clock::now() >= *quiet_until) + { + break; + } + } + co_await observer_interval(); + owner.check_executor(); + } +} + +void check_mysql() { + ark::mysql_options options; + options.pool.connection.host = environment("ARK_DATABASE_MYSQL_HOST"); + options.pool.connection.port = port("ARK_DATABASE_MYSQL_PORT"); + options.pool.connection.username = environment("ARK_DATABASE_MYSQL_USER"); + options.pool.connection.password = environment("ARK_DATABASE_MYSQL_PASSWORD", true); + options.pool.connection.database = environment("ARK_DATABASE_MYSQL_DATABASE"); + options.pool.connection.ca_file = environment("ARK_DATABASE_MYSQL_CA"); + options.pool.connection.tls = arknet::mysql::tls_mode::required; + options.pool.connection.timeout = 5s; + options.pool.max_connections = 1; + const auto tag = "ark_failure_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()); + mysql_slow_state state; + { + mysql_owner owner; + require(owner.database.configure("primary", options), "Cannot configure MySQL primary"); + auto wrong_password = options; + wrong_password.pool.connection.password += "-ark-rejected-" + tag; + require(owner.database.configure("wrong-password", std::move(wrong_password)), + "Cannot configure rejected MySQL credentials"); + auto untrusted = options; + untrusted.pool.connection.ca_file = ARK_DATABASE_UNTRUSTED_CA; + require(owner.database.configure("untrusted-ca", std::move(untrusted)), "Cannot configure untrusted MySQL CA"); + require(owner.database.configure("observer", options), "Cannot configure MySQL observer"); + owner.preflight(mysql_preflight(owner)); + owner.start(mysql_slow(owner, state, tag)); + owner.start(mysql_observe(owner, state, tag)); + owner.verify(); + } + require(state.observed && state.recovered && state.result && state.result->result.status.outcome_unknown, + "MySQL deadline result/lifecycle evidence did not survive module destruction"); +} +#endif + +} // namespace + +int main(int argc, char** argv) { + try + { + require(argc == 2, "Usage: ark_database_public_failure_service_tests redis|mysql"); + const std::string_view protocol(argv[1]); + if (protocol == "redis") + { + check_redis(); + } +#if defined(ARKNET_ENABLE_SSL) + else if (protocol == "mysql") + { + check_mysql(); + } +#endif + else + { + throw acceptance_failure("Requested failure-test protocol is not enabled"); + } + std::cout << protocol << " public module real-service failure checks passed: " << checks << '\n'; + return 0; + } + catch (const acceptance_failure& error) + { std::cerr << error.what() << '\n'; } + catch (...) + { + // Never emit authentication configuration or server-controlled messages. + std::cerr << "Public database failure acceptance raised an unexpected exception\n"; + } + return 1; +} From a4dd1aaf5c6050153b26e4d767ce0c9114dd4a7d Mon Sep 17 00:00:00 2001 From: nick Date: Tue, 20 Oct 2026 11:26:35 +0800 Subject: [PATCH 3/3] test(database): materialize Redis commands before coroutine awaits --- tests/database/public_failure_service.cpp | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/database/public_failure_service.cpp b/tests/database/public_failure_service.cpp index a8bc4e96a..c8f7d0817 100644 --- a/tests/database/public_failure_service.cpp +++ b/tests/database/public_failure_service.cpp @@ -172,22 +172,26 @@ void redis_success(const arknet::redis::result& result) { ark::task<> redis_preflight(redis_owner& owner, std::string key) { ark::redis_module& database = owner.database; - auto rejected = co_await database.command("wrong-password", {"PING"}, 5s); + arknet::redis::command rejected_ping{"PING"}; + auto rejected = co_await database.command("wrong-password", std::move(rejected_ping), 5s); owner.check_executor(); require(rejected.error == arknet::redis::client_errc::handshake_failed && !rejected.command_may_have_run, "Wrong Redis password lost its known-unsent handshake failure"); require(!rejected.replies.empty() && rejected.replies[0].type == arknet::redis::value_type::error, "Wrong Redis password lost the native server error reply"); - auto recovered = co_await database.command("primary", {"PING"}, 5s); + arknet::redis::command recovery_ping{"PING"}; + auto recovered = co_await database.command("primary", std::move(recovery_ping), 5s); owner.check_executor(); redis_success(recovered); require(recovered.replies[0].text == "PONG", "Explicit Redis request did not recover with valid credentials"); - auto absent = co_await database.command("primary", {"EXISTS", std::move(key)}, 5s); + arknet::redis::command check_key{"EXISTS", std::move(key)}; + auto absent = co_await database.command("primary", std::move(check_key), 5s); owner.check_executor(); redis_success(absent); require(absent.replies[0].type == arknet::redis::value_type::integer && absent.replies[0].integer == 0, "Redis slow-test key already exists; refusing a destructive pop"); - auto observer = co_await database.command("observer", {"PING"}, 5s); + arknet::redis::command observer_ping{"PING"}; + auto observer = co_await database.command("observer", std::move(observer_ping), 5s); owner.check_executor(); redis_success(observer); require(observer.replies[0].text == "PONG", "Redis observer could not authenticate"); @@ -204,7 +208,8 @@ ark::task<> redis_slow(redis_owner& owner, redis_slow_state& state, std::string // An absent unique key and no producer keep the server blocked for 30 seconds. // The independently warmed client deadline is two seconds. const auto started = std::chrono::steady_clock::now(); - state.result = co_await owner.database.command("primary", {"BLPOP", std::move(key), "30"}, 2s); + arknet::redis::command block{"BLPOP", std::move(key), "30"}; + state.result = co_await owner.database.command("primary", std::move(block), 2s); owner.check_executor(); require(std::chrono::steady_clock::now() - started < 5s, "Redis client deadline was not delivered well before its server block timeout"); @@ -272,7 +277,8 @@ ark::task<> redis_observe(redis_owner& owner, redis_slow_state& state, std::stri { owner.check_failure(); require(std::chrono::steady_clock::now() < deadline, "Redis observer did not see deadline and recovery"); - auto listing = co_await owner.database.command("observer", {"CLIENT", "LIST"}, 3s); + arknet::redis::command clients{"CLIENT", "LIST"}; + auto listing = co_await owner.database.command("observer", std::move(clients), 3s); owner.check_executor(); const auto blocked = blocked_redis_client(listing, tag); if (blocked) @@ -287,7 +293,8 @@ ark::task<> redis_observe(redis_owner& owner, redis_slow_state& state, std::stri require(state.observed, "Redis deadline completed before server-visible BLPOP observation"); require(state.result->error == arknet::redis::client_errc::timeout && state.result->command_may_have_run, "Sent Redis deadline lost its timeout/unknown execution state"); - auto recovered = co_await owner.database.command("primary", {"PING"}, 5s); + arknet::redis::command recovery_ping{"PING"}; + auto recovered = co_await owner.database.command("primary", std::move(recovery_ping), 5s); owner.check_executor(); redis_success(recovered); require(recovered.replies[0].text == "PONG", "Explicit new Redis request did not recover after deadline");