docs/superpowers/plans/2026-04-09-mysqlx-comprehensive-testing.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Achieve thorough test coverage of the mysqlx plugin across all layers — pure unit tests for protocol/config/stats, integration tests for admin commands and plugin lifecycle, and end-to-end tests with a real MySQL X Protocol backend.
Architecture: Tests are organized in three tiers. Tier 1 (pure unit) has zero external dependencies. Tier 2 (integration) uses in-memory SQLite3 and the plugin .so. Tier 3 (end-to-end) requires Docker MySQL 8.x. Each test file is self-contained and runnable independently.
Tech Stack: TAP framework (tap.h), in-memory SQLite3, socketpair() for protocol tests, Docker Compose for E2E
Current state: 112 assertions across 10 test files. Target: 400+ assertions.
All 14 tasks reconciled against the actual test tree on ProtocolX-rebased. Every Tier-1 / Tier-2 task hit or exceeded its assertion target; both Tier-3 e2e tests landed; one task is obsolete because its target file was deleted with the dormant MysqlxWorker path.
| # | Task | Target | Actual plan() | Status |
|---|---|---|---|---|
| 1 | mysqlx_protocol_unit-t | 42 | 42 | done |
| 2 | mysqlx_stats_unit-t | 22 | 22 | done |
| 3 | mysqlx_config_store_pure_unit-t | 25 | 25 | done |
| 4 | mysqlx_route_store_unit-t | 26 | 26 | done |
| 5 | plugin_manager_unit-t | 20 | 96 | exceeds |
| 6 | plugin_registry_unit-t | 25 | 68 | exceeds |
| 7 | test_mysqlx_admin_tables-t | 42 | 43 | done |
| 8 | mysqlx_admin_schema_unit-t | 15 | 25 | exceeds |
| 9 | test_mysqlx_listener_smoke-t | 15 | — | obsolete (file removed in 98aee7db2 with MysqlxWorker; lifecycle now covered by mysqlx_thread_unit-t, mysqlx_robustness_unit-t) |
| 10 | mysqlx_protocol_socket_unit-t | 20 | 20 | done |
| 11 | mysqlx_config_store_concurrent_unit-t | 15 | 15 | done |
| 12 | plugin_config_unit-t | 20 | 48 | exceeds |
| 13 | test_mysqlx_e2e_handshake-t | 10 | 10 | done |
| 14 | test_mysqlx_e2e_routing-t | 10 | 10 | done |
Total tracked assertions across the 13 in-scope files: 485 vs the plan's 307 target (Task 9 dropped). Beyond the plan, the branch also carries 17 additional mysqlx/plugin unit-test files that did not exist when the plan was written (e.g. mysqlx_compression_unit-t from the Phase 1–3 X Protocol compression work, mysqlx_session_unit-t, mysqlx_thread_unit-t, mysqlx_robustness_unit-t, mysqlx_tls_unit-t, plugin_lifecycle_unit-t, plugin_dispatch_unit-t, plugin_query_hook_unit-t, plugin_prometheus_unit-t, mysqlx_credential_verify_unit-t, mysqlx_backend_auth_unit-t, mysqlx_admin_commands_unit-t, mysqlx_admin_disk_commands_unit-t, mysqlx_data_stream_unit-t, mysqlx_connection_unit-t, mysqlx_concurrent_unit-t, mysqlx_message_dispatch_unit-t).
No new test work is required by this plan. Task-level commit checkboxes below are marked done; the per-assertion checkboxes are not individually flipped because the assertion budgets are met or exceeded by the existing implementations.
| Test File | Module | Tests |
|---|---|---|
mysqlx_protocol_unit-t.cpp (expand) | Protocol frame, auth | 10 → 40+ |
mysqlx_stats_unit-t.cpp (expand) | Stats counters + flush | 7 → 20+ |
NEW: mysqlx_config_store_pure_unit-t.cpp | Config store logic | 0 → 25+ |
| Test File | Module | Tests |
|---|---|---|
plugin_manager_unit-t.cpp (expand) | Plugin lifecycle | 7 → 20+ |
plugin_registry_unit-t.cpp (expand) | Table/command registry | 15 → 25+ |
plugin_config_unit-t.cpp (expand) | Config parsing, free functions | 12 → 20+ |
mysqlx_config_store_unit-t.cpp (expand) | Runtime loading, identity | 16 → 30+ |
mysqlx_route_store_unit-t.cpp (expand) | Routing strategies | 8 → 25+ |
test_mysqlx_admin_tables-t.cpp (expand) | LOAD/SAVE commands | 23 → 40+ |
NEW: mysqlx_admin_schema_unit-t.cpp | DDL validation, command names | 0 → 15+ |
NEW: test_mysqlx_listener_smoke-t.cpp (expand) | Listener lifecycle | 8 → 15+ |
| Test File | Module | Tests |
|---|---|---|
NEW: test_mysqlx_e2e_handshake-t.cpp | Full X Protocol handshake | 0 → 10+ |
NEW: test_mysqlx_e2e_routing-t.cpp | Query routing via X Protocol | 0 → 10+ |
NEW: test_mysqlx_e2e_failover-t.cpp | Backend failover | 0 → 8+ |
mysqlx_protocol_unit-t.cpp (10 → 42 assertions)Files:
test/tap/tests/unit/mysqlx_protocol_unit-t.cppAdd these test groups as static functions, called from main():
mysqlx_decode_frame_header with nullptr data returns nulloptmysqlx_decode_frame_header with exactly 5 bytes containing zero payload_size (boundary)mysqlx_encode_frame_header roundtrip — encode then decode, all fields preservedmysqlx_encode_frame_header with payload_size = MYSQLX_MAX_PAYLOAD_SIZE (16MB boundary)mysqlx_decode_frame_header with payload_size = 0 and message_type = 0build_frame header correctmysqlx_build_frame payload bytes match input after 5-byte headermysqlx_build_frame with empty payload produces 5-byte outputmysqlx_build_frame total size = 5 + payload.size()mysqlx_is_supported_auth_method("PLAIN") returns truemysqlx_is_supported_auth_method("") returns falsemysqlx_is_supported_auth_method("mysql41") returns truemysqlx_mysql41_verify returns false with wrong passwordmysqlx_mysql41_hash("") returns 20-byte result (SHA1 of empty string)mysqlx_mysql41_scramble with same inputs produces same output (deterministic)mysqlx_mysql41_scramble with different challenges produces different scramblesmysqlx_mysql41_verify with empty challenge and empty response returns falsemysqlx_mysql41_verify with truncated response (10 bytes instead of 20) returns falsemysqlx_mysql41_scramble with 20-byte challenge and long password works correctlymysqlx_hex_encode({0x00}) returns "00"mysqlx_hex_encode({0xFF}) returns "ff" (lowercase)mysqlx_hex_encode({0xAB, 0xCD}) returns "abcd"mysqlx_hex_encode({}) (empty) returns ""mysqlx_hex_decode("00", out) → out[0] == 0x00mysqlx_hex_decode("aBcD", out) → out = {0xAB, 0xCD} (case insensitive)mysqlx_hex_decode("", out) → out is empty, returns truemysqlx_hex_decode("ZZZ", out) returns false (invalid hex chars)mysqlx_hex_decode("A", out) returns false (odd length)mysqlx_send_error on a socketpair fd — read frame, verify type is Error, parse code and messagemysqlx_send_error with custom SQL state — verify state in payloadmysqlx_send_ok on a socketpair fd — read frame, verify type is Okmysqlx_send_ok with custom message — verify message in payloadmysqlx_send_error with empty message — does not crashNote: For send_error/send_ok tests, use socketpair(AF_UNIX, SOCK_STREAM, 0, fds) to get a connected pair of fds without needing real network.
Plan: plan(42)
test: expand mysqlx protocol unit tests to 42 assertionsmysqlx_stats_unit-t.cpp (7 → 22 assertions)Files:
test/tap/tests/unit/mysqlx_stats_unit-t.cppget_conn_ok("nonexistent") returns 0get_conn_err("nonexistent") returns 0record_conn_ok on same route accumulates correctly (3 calls → value 3)record_conn_ok and record_conn_err on same route are independent"route-with-dashes") worksstats_mysqlx_routes has 0 rows"route'name") — no SQL injectionrecord_conn_ok("route") — final value is 4000record_conn_ok + 500 record_conn_err on same route — final conn_ok=2000, conn_err=2000record_conn_ok while flush_to_sqlite is running — no crash, no data corruptionflush_to_sqlite while counters are being incremented — flushed values are self-consistent (may be slightly stale)Plan: plan(22)
test: expand mysqlx stats unit tests to 22 assertionsmysqlx_config_store_pure_unit-t.cpp (25 assertions)Files:
test/tap/tests/unit/mysqlx_config_store_pure_unit-t.cpptest/tap/tests/unit/Makefile (add build target)This tests MysqlxConfigStore methods that don't need SQLite — identity resolution, endpoint picking, topology generation — by calling load_from_runtime from a separate test that creates the DB, or by directly testing the public API with data already loaded.
Actually, since load_from_runtime is the only way to populate the store, and it needs SQLite3DB, this file should use in-memory SQLite3DB (like mysqlx_config_store_unit-t.cpp does).
mysqlx_backend_auth_mode_from_string("mapped") returns mappedmysqlx_backend_auth_mode_from_string("MAPPED") returns mapped (case insensitive)mysqlx_backend_auth_mode_from_string("service_account") returns service_accountmysqlx_backend_auth_mode_from_string("pass_through") returns pass_throughmysqlx_backend_auth_mode_from_string("unknown_value") returns mapped (default)mysqlx_backend_auth_mode_from_string("") returns mapped (default)resolve_identity("nonexistent_user") returns nulloptresolve_identity for user in mysql_users but NOT in mysqlx_users — returns identity with x_enabled = falseresolve_identity for user in mysqlx_users but NOT in mysql_users — returns nullopt (canonical wins)resolve_identity after load_from_runtime with empty mysql_users — returns nulloptresolve_identity after load_from_runtime with empty mysqlx_users — returns identity with defaultsresolve_identity returns correct identity for eachactive=0) in mysql_users is excludedresolve_identity with backend=1 users excluded (only frontend=1)pick_endpoint("nonexistent_route") returns empty endpointpick_endpoint for route whose hostgroup has no online servers — returns emptypick_endpoint for route with fallback_hostgroup, primary empty — uses fallbackpick_endpoint for route with fallback_hostgroup = -1, primary empty — returns emptypick_endpoint returns endpoint with mysqlx_port from mysqlx_backend_endpoints overridepick_endpoint returns endpoint with default mysqlx_port (33060) when no override existsload_from_runtime called twice — second call replaces all data (no stale entries)topology_generation() starts at 0bump_topology_generation() increments by 1load_from_runtime (is NOT reset)Plan: plan(25)
Build: Same pattern as mysqlx_config_store_unit-t — compiles mysqlx_config_store.cpp directly with the test harness.
test: add mysqlx config store pure unit tests (25 assertions)mysqlx_route_store_unit-t.cpp (8 → 26 assertions)Files:
test/tap/tests/unit/mysqlx_route_store_unit-t.cppfirst_available with 3 endpoints always returns first (never rotates)round_robin followed by first_available on same hostgroup — first_available always returns firstround_robin_with_fallback primary has servers — does NOT use fallbackactive=0 is not loaded — pick_endpoint returns emptypick_endpoint returns empty for alluse_ssl=1 override — picked endpoint has use_ssl=truePlan: plan(26)
test: expand mysqlx route store unit tests to 26 assertionsplugin_manager_unit-t.cpp (7 → 20 assertions)Files:
test/tap/tests/unit/plugin_manager_unit-t.cppload("") with empty path — returns false with error messageload with path to regular text file (not a .so) — returns false with dlopen errorload with path to .so missing proxysql_plugin_descriptor_v1 symbol — returns falseload same plugin twice — both succeed, size() == 2init_all with no plugins — returns true, no errorstart_all with no plugins — returns truestop_all with no plugins — returns truestop_all before start_all — returns true (idempotent)init_all called twice — returns true (idempotent)init callback — init_all succeeds, plugin marked initializedstart callback — start_all succeeds, plugin marked startedstop callback — stop_all succeedsPlan: plan(20)
test: expand plugin manager unit tests to 20 assertionsplugin_registry_unit-t.cpp (15 → 25 assertions)Files:
test/tap/tests/unit/plugin_registry_unit-t.cppregister_command with empty string — returns falseregister_command with null callback — returns falseregister_command with whitespace-only string — canonicalizes to empty, returns falsedispatch_admin_command with empty SQL — returns falsedispatch_admin_command with unregistered command — returns falseregister_table with null table_name — returns falseregister_table with empty table_def — returns falseregister_table with same table name but different db_kind — both succeed (namespaced)Plan: plan(25)
test: expand plugin registry unit tests to 25 assertionstest_mysqlx_admin_tables-t.cpp (23 → 42 assertions)Files:
test/tap/tests/test_mysqlx_admin_tables-t.cppLOAD MYSQLX USERS TO RUNTIME with empty config table — runtime has 0 rows, successLOAD MYSQLX ROUTES TO RUNTIME with empty config table — runtime has 0 rows, successLOAD MYSQLX BACKEND ENDPOINTS TO RUNTIME with empty config table — runtime has 0 rows, success"LOAD MYSQLX USERS FROM MEMORY" dispatches correctly (alias 1)"LOAD MYSQLX USERS FROM MEM" dispatches correctly (alias 2)"LOAD MYSQLX USERS TO RUN" dispatches correctly (alias 3)"SAVE MYSQLX USERS TO MEM" dispatches correctly (save alias 1)"SAVE MYSQLX USERS FROM RUNTIME" dispatches correctly (save alias 2)"SAVE MYSQLX USERS FROM RUN" dispatches correctly (save alias 3)"LOAD MYSQLX ROUTES FROM MEMORY" dispatches correctly"LOAD MYSQLX BACKEND ENDPOINTS FROM MEMORY" dispatches correctlyPlan: plan(42)
test: expand mysqlx admin table tests to 42 assertionsmysqlx_admin_schema_unit-t.cpp (15 assertions)Files:
test/tap/tests/unit/mysqlx_admin_schema_unit-t.cpptest/tap/tests/unit/MakefileTests the DDL schema definitions and command registration in isolation, without loading the plugin .so.
mysqlx_users DDL contains username, active, require_tls, allowed_auth_methods, backend_auth_modemysqlx_routes DDL contains name, bind, destination_hostgroup, fallback_hostgroup, strategymysqlx_backend_endpoints DDL contains hostname, mysql_port, mysqlx_port, use_sslstats_mysqlx_routes DDL contains name, ConnOK, ConnERR, Bytes_data_sent, Bytes_data_recvstats_mysqlx_processlist DDL contains username, route, worker_id, backend_hostmysqlx_register_admin_schema with null register_table callback — returns falsemysqlx_register_admin_schema with null register_command callback — returns falsemysqlx_register_admin_schema with valid services — returns truePlan: plan(15)
Build: Compiles mysqlx_admin_schema.cpp directly with a mock ProxySQL_PluginServices.
test: add mysqlx admin schema unit tests (15 assertions)test_mysqlx_listener_smoke-t.cpp (8 → 15 assertions)Obsolete: the smoke test was deleted in commit
98aee7db2together with the dormantMysqlxWorkerpath it exercised. Listener-lifecycle coverage now lives inmysqlx_thread_unit-tandmysqlx_robustness_unit-t. Sub-checkboxes below are kept for historical reference; no further work required.
Files:
test/tap/tests/test_mysqlx_listener_smoke-t.cppmysqlx_listener_count() is 0 before any listeners startmysqlx_stop_listeners() — count drops to 00.0.0.0:PORT — TCP connect succeedsPlan: plan(15)
Note: Use high ports (46000-46999 range) to avoid conflicts. Clean up listeners between sub-tests.
test: expand mysqlx listener smoke tests to 15 assertionsmysqlx_protocol_socket_unit-t.cpp (20 assertions)Files:
test/tap/tests/unit/mysqlx_protocol_socket_unit-t.cpptest/tap/tests/unit/MakefileTests mysqlx_read_frame, mysqlx_write_all, mysqlx_read_exact using socketpair().
mysqlx_write_all + mysqlx_read_exact roundtrip — bytes matchmysqlx_build_frame + mysqlx_write_all on one fd, mysqlx_read_frame on other — header and payload matchmysqlx_read_frame with 0 bytes available — blocks (test with non-blocking + immediate EAGAIN)mysqlx_read_exact with partial data available — blocks until all bytes arrive (write rest in another thread)mysqlx_read_frame — returns false (EOF)mysqlx_write_all — returns false (EPIPE)mysqlx_read_exact with len=0 — returns true immediatelymysqlx_send_error → mysqlx_read_frame — verify frame type is server Errormysqlx_send_ok → mysqlx_read_frame — verify frame type is server Okmysqlx_send_error with 2-char SQL state — roundtrip preserves stateMYSQLX_MAX_PAYLOAD_SIZE - 1 — succeedsmysqlx_read_frame with payload_size exceeding MYSQLX_MAX_PAYLOAD_SIZE — returns falsemysqlx_read_frame with payload_size == 1 (just message_type, no body) — succeeds with empty payloadPlan: plan(20)
Build: Same pattern as mysqlx_protocol_unit-t but also links protobuf objects for frame building.
test: add mysqlx protocol socket unit tests (20 assertions)mysqlx_config_store_concurrent_unit-t.cpp (15 assertions)Files:
test/tap/tests/unit/mysqlx_config_store_concurrent_unit-t.cpptest/tap/tests/unit/MakefileTests thread safety of MysqlxConfigStore.
load_from_runtime while Thread B calls resolve_identity — no crashload_from_runtime while Thread B calls pick_endpoint — no crashload_from_runtime with data while readers are active — readers eventually see new data or old data (never partial)resolve_identity calls — all return valid results (no crash)load_from_runtime called from 2 threads sequentially (serialized by external lock) — both succeedpick_endpoint on same route — no crash, all get valid endpointspick_endpoint with round_robin strategy — no crash, counter advances correctlypick_endpoint while load_from_runtime replaces data — no crashtopology_generation read while bump_topology_generation in another thread — no crash, value is monotonicpick_endpoint calls each — no crash, no hangPlan: plan(15)
test: add mysqlx config store concurrent unit tests (15 assertions)plugin_config_unit-t.cpp (12 → 20 assertions)Files:
test/tap/tests/unit/plugin_config_unit-t.cppplugins=() — plugin_modules is emptyplugins=("path/a.so","path/b.so") — two entries storedplugins=("path with spaces.so") — path preserved with spacesplugins key — plugin_modules is empty (not an error)proxysql_load_plugin_modules_from_config called twice — second call replaces firstproxysql_stop_configured_plugins with null manager — returns trueproxysql_start_configured_plugins with null manager — returns trueproxysql_load_configured_plugins with empty list — returns true, manager is nullPlan: plan(20)
test: expand plugin config unit tests to 20 assertionsFiles:
test/infra/docker-compose-mysqlx.ymltest/tap/tests/test_mysqlx_e2e_handshake-t.cppThis task requires Docker MySQL 8.x with X Protocol enabled (port 33060).
test/infra/docker-compose-mysqlx.yml based on existing mysql84 compose, exposing port 33060mysqlx plugin and create test user with MYSQL41 authx_enabled=0 — server sends ErrorPlan: plan(10)
Note: This task depends on ProxySQL being built and running with the mysqlx plugin loaded, connected to a Docker MySQL 8.x backend. The test connects to ProxySQL's X port, not directly to MySQL.
test: add mysqlx E2E handshake test infrastructureFiles:
test/tap/tests/test_mysqlx_e2e_routing-t.cppSELECT 1, receive resultsetSELECT @@hostname returns backend hostname (confirms routing through proxy)Plan: plan(10)
Note: Requires same Docker infrastructure as Task 13.
test: add mysqlx E2E routing testEach new test file needs a corresponding build target in test/tap/tests/unit/Makefile. The pattern follows existing mysqlx tests:
$(PROXYSQL_PATH)/plugins/mysqlx/src/<file>.cpp with -I$(PROXYSQL_PATH)/plugins/mysqlx/include$(MYSQLX_PROTO_OBJS) and -lprotobuf -lssl -lcrypto.so: depend on mysqlx_plugin_build phony targetmysqlx_protocol_unit-t already usesNew test files to register in UNIT_TESTS list and groups.json:
mysqlx_config_store_pure_unit-t → unit-tests-g1
mysqlx_admin_schema_unit-t → unit-tests-g1
mysqlx_protocol_socket_unit-t → unit-tests-g1
mysqlx_config_store_concurrent_unit-t → unit-tests-g1
E2E tests to register:
test_mysqlx_e2e_handshake-t → new group: mysqlx-e2e-g1 (requires Docker)
test_mysqlx_e2e_routing-t → new group: mysqlx-e2e-g1
| File | Before | After | Delta |
|---|---|---|---|
| mysqlx_protocol_unit-t | 10 | 42 | +32 |
| mysqlx_stats_unit-t | 7 | 22 | +15 |
| mysqlx_config_store_pure_unit-t (NEW) | 0 | 25 | +25 |
| mysqlx_route_store_unit-t | 8 | 26 | +18 |
| plugin_manager_unit-t | 7 | 20 | +13 |
| plugin_registry_unit-t | 15 | 25 | +10 |
| test_mysqlx_admin_tables-t | 23 | 42 | +19 |
| mysqlx_admin_schema_unit-t (NEW) | 0 | 15 | +15 |
| test_mysqlx_listener_smoke-t | 8 | 15 | +7 |
| mysqlx_protocol_socket_unit-t (NEW) | 0 | 20 | +20 |
| mysqlx_config_store_concurrent_unit-t (NEW) | 0 | 15 | +15 |
| plugin_config_unit-t | 12 | 20 | +8 |
| test_mysqlx_e2e_handshake-t (NEW) | 0 | 10 | +10 |
| test_mysqlx_e2e_routing-t (NEW) | 0 | 10 | +10 |
| Total | 112 | 307 | +237 |