diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 1a56e55d5ac3c..64884c27868ae 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -44,8 +44,10 @@ IF(UNIX) SET_TARGET_PROPERTIES(mariadb PROPERTIES ENABLE_EXPORTS TRUE) ENDIF(UNIX) -MYSQL_ADD_EXECUTABLE(mariadb-test mysqltest.cc ${CMAKE_SOURCE_DIR}/sql/sql_string.cc COMPONENT Test) -SET_SOURCE_FILES_PROPERTIES(mysqltest.cc PROPERTIES COMPILE_FLAGS "-DTHREADS ${PCRE2_DEBIAN_HACK}") +MYSQL_ADD_EXECUTABLE(mariadb-test mysqltest.cc mysqltest_replay_server.cc + ${CMAKE_SOURCE_DIR}/sql/sql_string.cc COMPONENT Test) +SET_SOURCE_FILES_PROPERTIES(mysqltest.cc mysqltest_replay_server.cc + PROPERTIES COMPILE_FLAGS "-DTHREADS ${PCRE2_DEBIAN_HACK}") TARGET_LINK_LIBRARIES(mariadb-test ${CLIENT_LIB} pcre2-posix pcre2-8) SET_TARGET_PROPERTIES(mariadb-test PROPERTIES ENABLE_EXPORTS TRUE) diff --git a/client/mysqltest.cc b/client/mysqltest.cc index 316d6c51cd5bd..98aa6c8ac0efa 100644 --- a/client/mysqltest.cc +++ b/client/mysqltest.cc @@ -41,6 +41,7 @@ #include #include #include "client_metadata.h" +#include "mysqltest_replay_server.h" #include #include #include @@ -128,12 +129,16 @@ static my_bool view_protocol= 0, view_protocol_enabled= 0; static my_bool service_connection_enabled= 1; static my_bool cursor_protocol= 0, cursor_protocol_enabled= 0; static my_bool parsing_disabled= 0; -static my_bool display_result_vertically= FALSE, display_result_lower= FALSE, +/* display_result_vertically is also read by mysqltest_replay_server.cc */ +my_bool display_result_vertically= FALSE; +static my_bool display_result_lower= FALSE, display_metadata= FALSE, display_result_sorted= FALSE, display_session_track_info= FALSE; static my_bool disable_query_log= 0, disable_result_log= 0; static my_bool disable_connect_log= 0; -static my_bool disable_warnings= 0, disable_column_names= 0; +/* disable_warnings is also read by mysqltest_replay_server.cc */ +my_bool disable_warnings= 0; +static my_bool disable_column_names= 0; static my_bool prepare_warnings_enabled= 0; static my_bool disable_info= 1; static my_bool abort_on_error= 1, opt_continue_on_error= 0; @@ -242,7 +247,8 @@ static struct st_test_file file_stack[16]; static struct st_test_file* cur_file; static struct st_test_file* file_stack_end; -static CHARSET_INFO *charset_info= &my_charset_latin1; /* Default charset */ +/* Default charset; also read by mysqltest_replay_server.cc */ +CHARSET_INFO *charset_info= &my_charset_latin1; static const char *embedded_server_groups[]= { @@ -277,6 +283,8 @@ static regex_t ps2_re; /* the query can be run using PS protocol with second static regex_t sp_re; /* the query can be run as a SP */ static regex_t view_re; /* the query can be run as a view*/ static regex_t cursor_re; /* the query can be run with cursor protocol*/ +static regex_t explain_re; /* the query is EXPLAIN (any variant) */ +static regex_t explain_for_conn_re; /* the query is EXPLAIN ... FOR CONNECTION ... */ static void init_re(void); static int match_re(regex_t *, char *); @@ -1877,6 +1885,8 @@ void free_used_memory() uint i; DBUG_ENTER("free_used_memory"); + replay_free(); + if (connections) { close_connections(); @@ -11181,7 +11191,184 @@ int append_warnings(DYNAMIC_STRING *ds, MYSQL* mysql) /* - Handle situation where query is sent but there is no active connection + Helpers the replay-server mode (mysqltest_replay_server.cc) shares with the + rest of mysqltest. +*/ + +/* + Check if query is of the form "EXPLAIN ..." that we want to handle via the + replay server. + + Returns TRUE if the query starts with the EXPLAIN keyword. + Returns FALSE for "EXPLAIN ... FOR CONNECTION ..." forms (e.g. + "EXPLAIN FOR CONNECTION " or "EXPLAIN FORMAT=JSON FOR CONNECTION "), + since that form does not trigger query optimization/recording. + + Uses the precompiled regexes explain_re / explain_for_conn_re (see init_re). +*/ +my_bool is_explain_query(const char *query, size_t query_len) +{ + const char *p= query, *end= query + query_len; + char stack_buf[512]; + char *buf; + my_bool result; + + /* + Both regexes below can only match a query that begins with the EXPLAIN + keyword. Nearly every query - and every intermediate statement of a + replayed context script - is not one, so answer those here, without the + null-terminating copy and the two regexecs. + + The regexes are compiled REG_ICASE and their [[:space:]] is ASCII, hence + the ASCII comparisons; charset_info is consulted as well so that the + skipped whitespace stays a superset of the regexes'. + */ + while (p < end && (my_isspace(charset_info, *p) || + my_isspace(&my_charset_latin1, *p))) + p++; + if ((size_t) (end - p) < 7 || strncasecmp(p, "EXPLAIN", 7)) + return FALSE; + + /* match_re / regexec need a null-terminated string; query isn't guaranteed + to be null-terminated at query_len. Copy into a temp buffer. */ + if (query_len + 1 <= sizeof(stack_buf)) + buf= stack_buf; + else + buf= (char*) my_malloc(PSI_NOT_INSTRUMENTED, query_len + 1, MYF(MY_WME)); + if (!buf) + return FALSE; + + memcpy(buf, query, query_len); + buf[query_len]= '\0'; + + result= match_re(&explain_re, buf) && !match_re(&explain_for_conn_re, buf); + + if (buf != stack_buf) + my_free(buf); + return result; +} + + +/* + Print the current test-file location (file, line, and include stack) to the + given stream, each output line prefixed with `prefix`. Mirrors the format + used by make_error_message() for regular mysqltest errors. +*/ +void print_test_location(FILE *f, const char *prefix) +{ + if (cur_file && cur_file != file_stack) + { + /* Enough for the full 16-entry include stack. */ + char buf[4096]; + buf[0]= '\0'; + fprintf(f, "%sIn included file \"%s\":\n", prefix, cur_file->file_name); + print_file_stack(buf, buf + sizeof(buf)); + if (buf[0]) + fprintf(f, "%s%s", prefix, buf); + } + else if (cur_file && cur_file->file_name) + { + fprintf(f, "%sIn file \"%s\"\n", prefix, cur_file->file_name); + } + if (start_lineno > 0) + fprintf(f, "%sAt line %u\n", prefix, start_lineno); +} + + +/* + Pre/post query hooks + ~~~~~~~~~~~~~~~~~~~~ + run_query_normal() sends a query to the test server, reads its result sets + and formats them into `ds`. Optional features need to do work around that: + before the query is sent, in place of its first result set, and after it is + done. Instead of spreading such code through run_query_normal(), it is + reached through the four query_hooks_*() entry points below. + + The only hook today is the replay-server mode of mtr --replay-server: it + makes the test server record the optimizer context of an EXPLAIN, replays + that context on a second ("replay") server and puts the replay server's + EXPLAIN output into the test result in place of the test server's own. + + struct st_query_hooks holds the per-query state of the hooks. It lives on + run_query_normal()'s stack and starts out all-FALSE, i.e. "no hook is + active for this query". +*/ +struct st_query_hooks +{ + /* The replay hook is active and owns this query's first result set */ + my_bool replay_active; +}; + + +/* + Called once per query, just before it is sent to the test server. Also + called for queries that end up not being sent, so that one-shot flags such + as "disable_replay next_query" are consumed exactly once per query. +*/ +static void query_hooks_pre_query(struct st_query_hooks *hooks, MYSQL *mysql, + int flags, const char *query, + size_t query_len) +{ + /* A hook can only replace a result set of a query that is reaped here */ + my_bool complete_query= (flags & (QUERY_SEND_FLAG | QUERY_REAP_FLAG)) == + (QUERY_SEND_FLAG | QUERY_REAP_FLAG); + hooks->replay_active= replay_hook_pre_query(mysql, complete_query, query, + query_len); +} + + +/* + TRUE if a hook produces the output of result set number `counter` itself. + run_query_normal() must then append neither the table headings nor the rows + of that result set, and call query_hooks_result() instead. + + A hook produces its replacement by running queries of its own on `mysql`, + which it cannot do while the server still has result sets pending for the + current query - that fails with CR_COMMANDS_OUT_OF_SYNC. A multi-statement + query whose first statement is an EXPLAIN is therefore left to the ordinary + path, so that the test server's own output is used; the setup the hook did + in query_hooks_pre_query() is undone by query_hooks_post_query(). +*/ +static my_bool query_hooks_own_result(const struct st_query_hooks *hooks, + MYSQL *mysql, int counter) +{ + return hooks->replay_active && counter == 0 && !mysql_more_results(mysql); +} + + +/* + Append the output of a result set claimed by query_hooks_own_result() to + `ds`. Consumes *res: it is up to the hook how much, if any, of the test + server's own result ends up in `ds`. +*/ +static void query_hooks_result(struct st_query_hooks *hooks, MYSQL *mysql, + MYSQL_RES **res, MYSQL_FIELD *fields, + uint num_fields, const char *query, + size_t query_len, DYNAMIC_STRING *ds) +{ + DBUG_ASSERT(hooks->replay_active); + replay_hook_result(mysql, res, fields, num_fields, query, query_len, ds); + hooks->replay_active= FALSE; +} + + +/* + Called on every exit path of run_query_normal(), including the error ones. + A hook that is still active here never got to see a result set, so this is + where it undoes what it did in query_hooks_pre_query(). +*/ +static void query_hooks_post_query(struct st_query_hooks *hooks, MYSQL *mysql) +{ + if (hooks->replay_active) + { + replay_undo_test_server_setup(mysql); + hooks->replay_active= FALSE; + } +} + + +/* + Handle situation where query is sent but there is no active connection (e.g directly after disconnect). We emulate MySQL-compatible behaviour of sending something on a closed @@ -11205,43 +11392,6 @@ void run_execute_stmt(struct st_connection *cn, struct st_command *command, cons void run_close_stmt(struct st_connection *cn, struct st_command *command, const char *query, size_t query_len, DYNAMIC_STRING *ds, DYNAMIC_STRING *ds_warnings); -static void do_disable_replay(struct st_command *command) -{ - const char *p= command->first_argument; - const char *end= command->end; - const char *tok; - size_t tok_len; - DBUG_ENTER("do_disable_replay"); - - /* Skip leading whitespace */ - while (p < end && my_isspace(charset_info, *p)) - p++; - - tok= p; - while (p < end && !my_isspace(charset_info, *p)) - p++; - tok_len= (size_t)(p - tok); - - if ((tok_len == 10 && strncmp(tok, "next_query", 10) == 0) || - (tok_len == 8 && strncmp(tok, "testfile", 8) == 0)) - { - /* Token is correct. */ - } - else - die("Syntax: disable_replay next_query|testfile "); - - /* Skip whitespace between the scope token and the reason */ - while (p < end && my_isspace(charset_info, *p)) - p++; - - if (p >= end) - die("Syntax: disable_replay next_query|testfile (reason missing)"); - - command->last_argument= command->end; - DBUG_VOID_RETURN; -} - - /* Run query using MySQL C API @@ -11262,6 +11412,7 @@ void run_query_normal(struct st_connection *cn, struct st_command *command, MYSQL_RES *res= 0; MYSQL *mysql= cn->mysql; int err= 0, counter= 0; + struct st_query_hooks hooks= { FALSE }; DBUG_ENTER("run_query_normal"); DBUG_PRINT("enter",("flags: %d", flags)); DBUG_PRINT("enter", ("query: '%-.60s'", query)); @@ -11298,6 +11449,8 @@ void run_query_normal(struct st_connection *cn, struct st_command *command, break; } + query_hooks_pre_query(&hooks, mysql, flags, query, query_len); + if (flags & QUERY_SEND_FLAG) { /* @@ -11349,13 +11502,20 @@ void run_query_normal(struct st_connection *cn, struct st_command *command, MYSQL_FIELD *fields= mysql_fetch_fields(res); uint num_fields= mysql_num_fields(res); + my_bool hook_owns_result= query_hooks_own_result(&hooks, mysql, counter); + if (display_metadata) append_metadata(ds, fields, num_fields); - if (!display_result_vertically) + /* A hook that owns this result set supplies the headings itself */ + if (!display_result_vertically && !hook_owns_result) append_table_headings(ds, fields, num_fields); - append_result(ds, res); + if (hook_owns_result) + query_hooks_result(&hooks, mysql, &res, fields, num_fields, + query, query_len, ds); + else + append_result(ds, res); } /* @@ -11413,6 +11573,13 @@ void run_query_normal(struct st_connection *cn, struct st_command *command, variable then can be used from the test case itself. */ var_set_errno(mysql_errno(mysql)); + + /* + Undoing a hook's setup runs statements of its own on the connection, so + it has to come after the errno of the query itself has been saved - those + statements succeed and would otherwise leave $mysql_errno at 0. + */ + query_hooks_post_query(&hooks, mysql); DBUG_VOID_RETURN; } @@ -12835,11 +13002,31 @@ void init_re(void) "^(" "[[:space:]]*SELECT[[:space:]])"; + /* + Filter: query starts with the EXPLAIN keyword. + */ + const char *explain_re_str = + "^[[:space:]]*EXPLAIN([[:space:]]|$)"; + + /* + Filter: EXPLAIN ... FOR CONNECTION ... (any EXPLAIN options between). + Matches forms like: + EXPLAIN FOR CONNECTION + EXPLAIN FORMAT=JSON FOR CONNECTION + EXPLAIN EXTENDED FOR CONNECTION + The query body of a real EXPLAIN never ends with "FOR CONNECTION", so a + plain substring-style match is safe in practice. + */ + const char *explain_for_conn_re_str = + "^[[:space:]]*EXPLAIN[[:space:]](.*[[:space:]])?FOR[[:space:]]+CONNECTION([[:space:]]|$)"; + init_re_comp(&ps_re, ps_re_str); init_re_comp(&ps2_re, ps2_re_str); init_re_comp(&sp_re, sp_re_str); init_re_comp(&view_re, view_re_str); init_re_comp(&cursor_re, cursor_re_str); + init_re_comp(&explain_re, explain_re_str); + init_re_comp(&explain_for_conn_re, explain_for_conn_re_str); } @@ -12878,6 +13065,8 @@ void free_re(void) regfree(&sp_re); regfree(&view_re); regfree(&cursor_re); + regfree(&explain_re); + regfree(&explain_for_conn_re); } /****************************************************************************/ @@ -13222,6 +13411,8 @@ int main(int argc, char **argv) var_set_string("MYSQLTEST_FILE", cur_file->file_name); init_re(); + replay_init(result_file_name); + /* Cursor protocol implies ps protocol */ if (cursor_protocol) ps_protocol= 1; @@ -13612,8 +13803,14 @@ int main(int argc, char **argv) enable_optimizer_trace(cur_con); break; case Q_DISABLE_REPLAY: - do_disable_replay(command); + { + const char *err= replay_do_disable(command->first_argument, + command->end); + if (err) + die("%s", err); + command->last_argument= command->end; break; + } case Q_SEND_SHUTDOWN: handle_command_error(command, mysql_shutdown(cur_con->mysql, diff --git a/client/mysqltest_replay_server.cc b/client/mysqltest_replay_server.cc new file mode 100644 index 0000000000000..90f3b17445d71 --- /dev/null +++ b/client/mysqltest_replay_server.cc @@ -0,0 +1,1434 @@ +/* Copyright (c) 2026, MariaDB plc + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; version 2 of the License. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1335 USA */ + +/* + Replay-server mode of mysqltest. See mysqltest_replay_server.h for what it + does and for the interface it shares with mysqltest.cc. +*/ + +#include "client_priv.h" +#include +#include "mysqltest_replay_server.h" + +/* + ReplayTest mode: names and literals shared with the outside world. + + Everything the replay code has to spell exactly right is defined here rather + than at the point of use: the environment variables are set by + mariadb-test-run.pl, the system variables and the @-variable are those the + recorded context script uses, and the file names are what a test run leaves + in vardir / next to the .result file. +*/ +/* Environment variables exported by mtr --replay-server & friends */ +#define REPLAY_ENV_SOCKET "REPLAY_SERVER_SOCKET" +#define REPLAY_ENV_TRACE "REPLAY_SERVER_TRACE" +#define REPLAY_ENV_NO_CLEANUP "REPLAY_SERVER_NO_CLEANUP" +/* Log of everything sent to the replay server, relative to MYSQLTEST_VARDIR */ +#define REPLAY_QUERY_LOG_SUBPATH "/log/replay_queries.log" +/* Marker written to that log before each replayed context script */ +#define REPLAY_LOG_SESSION_MARKER "### REPLAY SESSION ###" +/* Extensions of the two optimizer_trace dumps, replacing the .result one */ +#define REPLAY_TRACE_EXT_ORIGINAL ".opt_trace.original" +#define REPLAY_TRACE_EXT_REPLAY ".opt_trace.replay" +/* Database mysqltest connects the replay server with, and returns to after + cleanup. Never dropped by the cleanup, see replay_capture_snapshot(). */ +#define REPLAY_DEFAULT_DB "test" +/* System variable that makes the test server record an optimizer context */ +#define REPLAY_RECORD_SYSVAR "optimizer_record_context" +/* System variable that feeds a recorded context to the replay server */ +#define REPLAY_CONTEXT_SYSVAR "optimizer_replay_context" +/* User variable the recorded script keeps the context in */ +#define REPLAY_CONTEXT_USERVAR "@opt_context" +/* User variable holding the test server's optimizer_trace setting while + REPLAY_ENV_TRACE has it forced on for one EXPLAIN. Deliberately not the one + enable_optimizer_trace() uses, so that a test doing --enable_optimizer_trace + under REPLAY_ENV_TRACE still restores its own value. */ +#define REPLAY_SAVED_TRACE_USERVAR "@mtr_save_opt_trace" +/* Where the recorded context and the optimizer traces are read from */ +#define REPLAY_IS_CONTEXT_TABLE "information_schema.optimizer_context" +#define REPLAY_IS_TRACE_TABLE "information_schema.optimizer_trace" +/* Schemas the cleanup must never look at, and the extra ones that are only + excluded from the database list - their contents are still tracked */ +#define REPLAY_ENGINE_SCHEMAS "'information_schema','performance_schema'" +#define REPLAY_KEPT_SCHEMAS REPLAY_ENGINE_SCHEMAS ",'mysql','sys','" \ + REPLAY_DEFAULT_DB "'" +/* The recorded context script separates its statements with this */ +static const char replay_stmt_separator[]= ";\n"; +#define REPLAY_STMT_SEPARATOR_LEN (sizeof(replay_stmt_separator) - 1) +/* Scope keywords accepted by "disable_replay " */ +static const LEX_CSTRING replay_scope_next_query= + {STRING_WITH_LEN("next_query")}; +static const LEX_CSTRING replay_scope_testfile= {STRING_WITH_LEN("testfile")}; +#define DISABLE_REPLAY_SYNTAX "Syntax: disable_replay next_query|testfile " \ + "" + +/* ReplayTest mode variables */ +static MYSQL *replay_server_mysql= NULL; +static const char *replay_server_socket= NULL; +static FILE *replay_log_file= NULL; +static my_bool replay_server_trace= FALSE; +static FILE *replay_opt_trace_original_file= NULL; +static FILE *replay_opt_trace_replay_file= NULL; +static const char *replay_opt_trace_original_path= NULL; +static const char *replay_opt_trace_replay_path= NULL; +/* When non-NULL, the replay-side helpers append the replay server's + optimizer_trace into this buffer so the caller can decide whether to + actually flush it to disk. */ +static DYNAMIC_STRING *replay_trace_capture_buf= NULL; +/* Substrings whose presence in the optimizer_context script disables replay + for the current EXPLAIN; the primary server's result is used as-is. */ +static const char *replay_context_stop_words[]= { + "subquery_runs", + NULL +}; +static my_bool disable_replay_next_query= FALSE; +static char disable_replay_reason[512]= {0}; +static my_bool disable_replay_testfile= FALSE; +/* Restore the replay server to its baseline state after each replay run. + Cleared by REPLAY_SERVER_NO_CLEANUP, which leaves the objects created by + the last replay run in place for post-mortem inspection. */ +static my_bool replay_cleanup= TRUE; +/* One table or view on the replay server. Both members are my_malloc'ed. */ +struct st_replay_object +{ + char *db; + char *name; +}; +/* The state the replay server had when we connected to it: the databases + (system ones excluded) and the tables/views present at that point. Set up + by replay_capture_baseline(), both kept sorted for bsearch(). */ +static my_bool replay_baseline_valid= FALSE; +static DYNAMIC_ARRAY replay_baseline_dbs; /* of char* */ +static DYNAMIC_ARRAY replay_baseline_objects; /* of st_replay_object */ + +/* + ReplayTest mode helper functions +*/ + +static void replay_capture_baseline(); + +/* + Ensure connection to replay server is established + Returns 0 on success, non-zero on error +*/ +static int ensure_replay_server_connection() +{ + DBUG_ENTER("ensure_replay_server_connection"); + + if (replay_server_mysql) + DBUG_RETURN(0); + + replay_server_mysql= mysql_init(NULL); + if (!replay_server_mysql) + { + fprintf(stdout, "ReplayTest: Failed to initialize MySQL handle for replay server\n"); + DBUG_RETURN(1); + } + + if (!mysql_real_connect(replay_server_mysql, NULL, NULL, NULL, + REPLAY_DEFAULT_DB, 0, + replay_server_socket, CLIENT_MULTI_STATEMENTS)) + { + fprintf(stdout, "ReplayTest: Failed to connect to replay server at socket '%s': %d %s\n", + replay_server_socket, mysql_errno(replay_server_mysql), + mysql_error(replay_server_mysql)); + mysql_close(replay_server_mysql); + replay_server_mysql= NULL; + DBUG_RETURN(1); + } + + verbose_msg("ReplayTest: Connected to replay server (database: %s)", + REPLAY_DEFAULT_DB); + + /* + Replay server runs against arbitrary recorded contexts; disable FK + checks so trace-driven CREATE/INSERT side effects don't fail on + referential integrity. + */ + if (mysql_real_query(replay_server_mysql, + STRING_WITH_LEN("SET foreign_key_checks=0"))) + { + fprintf(stdout, + "ReplayTest: Warning - failed to set foreign_key_checks=0 " + "on replay server: %d %s\n", + mysql_errno(replay_server_mysql), + mysql_error(replay_server_mysql)); + } + + /* + REPLAY_SERVER_TRACE: enable optimizer_trace once, session-scoped, so all + subsequent replayed EXPLAINs leave a trace in I_S.optimizer_trace. + */ + if (replay_server_trace && + mysql_real_query(replay_server_mysql, + STRING_WITH_LEN("SET optimizer_trace='enabled=on'"))) + { + fprintf(stdout, + "ReplayTest: Warning - failed to enable optimizer_trace on " + "replay server: %d %s\n", + mysql_errno(replay_server_mysql), + mysql_error(replay_server_mysql)); + } + + /* + Remember what the server looks like before any replay script has run, so + that replay_restore_baseline() can undo what the scripts create. + */ + if (replay_cleanup) + replay_capture_baseline(); + + DBUG_RETURN(0); +} + + +/* + Log the start of a new replay session +*/ +static void log_replay_session_start() +{ + if (replay_log_file) + { + fprintf(replay_log_file, "%s\n", REPLAY_LOG_SESSION_MARKER); + fflush(replay_log_file); + } +} + +/* + Log a query being sent to the replay server +*/ +static void log_replay_query(const char *query, size_t query_len) +{ + if (replay_log_file) + { + fprintf(replay_log_file, "%.*s;\n", (int)query_len, query); + fflush(replay_log_file); + } +} + + +/* + Append the "Warnings:" block of the last statement run on `conn` to `ds`, + in the same format run_query_normal() uses. Does nothing when the test has + warnings disabled or when there are none. +*/ +static void replay_append_warnings(MYSQL *conn, DYNAMIC_STRING *ds) +{ + DYNAMIC_STRING ds_warn; + + if (disable_warnings) + return; + + init_dynamic_string(&ds_warn, "", 256, 256); + if (append_warnings(&ds_warn, conn) || ds_warn.length) + { + dynstr_append_mem(ds, STRING_WITH_LEN("Warnings:\n")); + dynstr_append_mem(ds, ds_warn.str, ds_warn.length); + } + dynstr_free(&ds_warn); +} + + +/* + REPLAY_SERVER_TRACE helper: fetch the current optimizer_trace from `conn` + and append the trace JSON (one row per result row, one newline each) into + `out`. On error appends a "-- ERROR: \n" line. Drains any extra result + sets so the connection is left clean. +*/ +static void capture_optimizer_trace(MYSQL *conn, DYNAMIC_STRING *out) +{ + if (!conn || !out) + return; + if (mysql_real_query(conn, + STRING_WITH_LEN("SELECT trace FROM " + REPLAY_IS_TRACE_TABLE))) + { + const char *err= mysql_error(conn); + dynstr_append_mem(out, STRING_WITH_LEN("-- ERROR: ")); + dynstr_append_mem(out, err, strlen(err)); + dynstr_append_mem(out, "\n", 1); + return; + } + MYSQL_RES *res= mysql_store_result(conn); + if (res) + { + MYSQL_ROW row; + while ((row= mysql_fetch_row(res))) + { + ulong *lens= mysql_fetch_lengths(res); + if (row[0]) + dynstr_append_mem(out, row[0], lens[0]); + dynstr_append_mem(out, "\n", 1); + } + mysql_free_result(res); + } + /* Drain any extra result sets (multi-statement safety). */ + while (mysql_next_result(conn) == 0) + { + MYSQL_RES *r= mysql_store_result(conn); + if (r) mysql_free_result(r); + } +} + + +/* + REPLAY_SERVER_TRACE helper: append a trace block (header line followed by + the previously-captured trace contents) to the file `*fpp`, lazy-opening it + from `path` on first use. +*/ +static void flush_trace_block(FILE **fpp, const char *path, + const char *header_query, size_t header_query_len, + const DYNAMIC_STRING *trace) +{ + if (!path || !trace) + return; + if (!*fpp) + { + *fpp= fopen(path, "a"); + if (!*fpp) + { + fprintf(stderr, + "ReplayTest: Warning - could not open optimizer_trace dump " + "file %s: %s\n", path, strerror(errno)); + return; + } + } + fprintf(*fpp, "-- EXPLAIN: %.*s\n", + (int)header_query_len, header_query ? header_query : ""); + if (trace->length) + fwrite(trace->str, 1, trace->length, *fpp); + fputc('\n', *fpp); + fflush(*fpp); +} + + +/* + Restoring the replay server between replay runs + =============================================== + + The replay server is a single instance shared by every test of an mtr run. + Each replay script creates databases, tables and views on it and never + removes them, so without cleanup the leftovers accumulate for the whole + run and can influence subsequent replays. + + The approach: right after connecting, take a snapshot of the databases and + of the tables/views that are present - the "baseline". After each replay + script, take another snapshot and drop everything that is not in the + baseline. This puts the server back into the baseline state, which is why + the baseline only has to be taken once. + + Known limitations. They are harmless when the replay server starts out + pristine, which is the case under mtr --replay-server: + - an object that a script re-created under a baseline name keeps the + script's definition, it is not restored to the baseline one; + - a baseline object that a script dropped is not re-created; + - system variables are not restored. Every script begins by setting the + full set of variables it cares about, but a variable that script N sets + and script N+1 does not mention keeps script N's value. +*/ + +static int cmp_replay_string(const void *a, const void *b) +{ + return strcmp(*(const char* const *) a, *(const char* const *) b); +} + + +static int cmp_replay_object(const void *a, const void *b) +{ + const struct st_replay_object *o1= (const struct st_replay_object*) a; + const struct st_replay_object *o2= (const struct st_replay_object*) b; + int res= strcmp(o1->db, o2->db); + return res ? res : strcmp(o1->name, o2->name); +} + + +/* Release a snapshot produced by replay_capture_snapshot() */ + +static void free_replay_snapshot(DYNAMIC_ARRAY *dbs, DYNAMIC_ARRAY *objects) +{ + uint i; + for (i= 0; i < dbs->elements; i++) + my_free(*(char**) dynamic_array_ptr(dbs, i)); + for (i= 0; i < objects->elements; i++) + { + struct st_replay_object *obj= + (struct st_replay_object*) dynamic_array_ptr(objects, i); + my_free(obj->db); + my_free(obj->name); + } + delete_dynamic(dbs); + delete_dynamic(objects); +} + + +/* Read and discard whatever the replay server still has pending */ + +static void replay_drain_results() +{ + do + { + MYSQL_RES *res= mysql_store_result(replay_server_mysql); + if (res) + mysql_free_result(res); + } while (mysql_next_result(replay_server_mysql) == 0); +} + + +/* + Take a snapshot of the replay server: the non-system databases into `dbs` + and every table/view/sequence outside of the engine-provided schemas into + `objects`. Both arrays are initialized here and are to be released with + free_replay_snapshot(); on return they are sorted. + + Only TABLE_SCHEMA and TABLE_NAME are read from I_S.TABLES. That keeps the + query at SKIP_OPEN_TABLE, i.e. a plain scan of the datadir; asking for + TABLE_TYPE as well would make the server parse every .frm. Views are + therefore not told apart from tables here - the cleanup just issues both + DROP VIEW and DROP TABLE, the same way the replay script itself does. + + The `test` database and the system schemas are left out of the database + list so that they can never be dropped. Their contents are still tracked: + a script whose default database is `test` creates its tables there. + + @return 0 on success. On error the arrays are released and left empty; a + partial snapshot must never be used to decide what to drop. +*/ + +static int replay_capture_snapshot(DYNAMIC_ARRAY *dbs, DYNAMIC_ARRAY *objects) +{ + static const char db_query[]= + "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME " + "NOT IN (" REPLAY_KEPT_SCHEMAS ")"; + static const char obj_query[]= + "SELECT TABLE_SCHEMA, TABLE_NAME FROM information_schema.TABLES WHERE " + "TABLE_SCHEMA NOT IN (" REPLAY_ENGINE_SCHEMAS ")"; + MYSQL_RES *res; + MYSQL_ROW row; + DBUG_ENTER("replay_capture_snapshot"); + + my_init_dynamic_array(PSI_NOT_INSTRUMENTED, dbs, sizeof(char*), + 16, 16, MYF(0)); + my_init_dynamic_array(PSI_NOT_INSTRUMENTED, objects, + sizeof(struct st_replay_object), 64, 64, MYF(0)); + + if (mysql_real_query(replay_server_mysql, db_query, sizeof(db_query) - 1) || + !(res= mysql_store_result(replay_server_mysql))) + goto error; + + while ((row= mysql_fetch_row(res))) + { + char *db; + if (!row[0] || + !(db= my_strdup(PSI_NOT_INSTRUMENTED, row[0], MYF(MY_WME)))) + { + mysql_free_result(res); + goto error; + } + if (insert_dynamic(dbs, &db)) + { + my_free(db); + mysql_free_result(res); + goto error; + } + } + mysql_free_result(res); + replay_drain_results(); + + if (mysql_real_query(replay_server_mysql, obj_query, sizeof(obj_query) - 1) || + !(res= mysql_store_result(replay_server_mysql))) + goto error; + + while ((row= mysql_fetch_row(res))) + { + struct st_replay_object obj; + if (!row[0] || !row[1] || + !(obj.db= my_strdup(PSI_NOT_INSTRUMENTED, row[0], MYF(MY_WME)))) + { + mysql_free_result(res); + goto error; + } + if (!(obj.name= my_strdup(PSI_NOT_INSTRUMENTED, row[1], MYF(MY_WME)))) + { + my_free(obj.db); + mysql_free_result(res); + goto error; + } + if (insert_dynamic(objects, &obj)) + { + my_free(obj.db); + my_free(obj.name); + mysql_free_result(res); + goto error; + } + } + mysql_free_result(res); + replay_drain_results(); + + my_qsort(dbs->buffer, dbs->elements, sizeof(char*), cmp_replay_string); + my_qsort(objects->buffer, objects->elements, + sizeof(struct st_replay_object), cmp_replay_object); + DBUG_RETURN(0); + +error: + fprintf(stdout, + "ReplayTest: Warning - failed to read the replay server state: " + "%d %s\n", + mysql_errno(replay_server_mysql), mysql_error(replay_server_mysql)); + replay_drain_results(); + free_replay_snapshot(dbs, objects); + DBUG_RETURN(1); +} + + +/* + Record the state the replay server is in before any replay script has run. + On failure the cleanup stays disabled: with an empty baseline every object + on the server, including the ones in the mysql schema, would look new. +*/ + +static void replay_capture_baseline() +{ + DBUG_ENTER("replay_capture_baseline"); + + if (replay_baseline_valid) + DBUG_VOID_RETURN; + + if (replay_capture_snapshot(&replay_baseline_dbs, &replay_baseline_objects)) + { + fprintf(stdout, "ReplayTest: Warning - no baseline could be taken, the " + "replay server will not be cleaned up\n"); + DBUG_VOID_RETURN; + } + replay_baseline_valid= TRUE; + verbose_msg("ReplayTest: baseline is %lu database(s), %lu table(s)/view(s)", + (ulong) replay_baseline_dbs.elements, + (ulong) replay_baseline_objects.elements); + DBUG_VOID_RETURN; +} + + +/* Append `name` to ds as a quoted identifier */ + +static void append_quoted_ident(DYNAMIC_STRING *ds, const char *name) +{ + const char *p; + dynstr_append_mem(ds, "`", 1); + for (p= name; *p; p++) + { + if (*p == '`') + dynstr_append_mem(ds, "``", 2); + else + dynstr_append_mem(ds, p, 1); + } + dynstr_append_mem(ds, "`", 1); +} + + +/* + Run one cleanup statement on the replay server. Failures are reported but + are not fatal: cleanup is best-effort, a failed drop must not abort a test. +*/ + +static void replay_exec_cleanup_query(const char *query, size_t len) +{ + log_replay_query(query, len); + if (mysql_real_query(replay_server_mysql, query, (ulong) len)) + { + fprintf(stdout, "ReplayTest: Warning - cleanup query failed: %.*s: %s\n", + (int) len, query, mysql_error(replay_server_mysql)); + print_test_location(stdout, "ReplayTest: "); + return; + } + replay_drain_results(); +} + + +/* + Undo what the replay script that has just finished did to the replay + server: drop the databases, tables and views it left behind, forget the + context it stored in @opt_context, and return to a known default database. + + Must run after the optimizer_trace of the replayed EXPLAIN has been + captured: the statements issued here overwrite it. +*/ + +static void replay_restore_baseline() +{ + DYNAMIC_ARRAY cur_dbs, cur_objects, extra_dbs; + DYNAMIC_STRING stmt; + uint i, dropped_objects= 0; + DBUG_ENTER("replay_restore_baseline"); + + if (replay_capture_snapshot(&cur_dbs, &cur_objects)) + { + fprintf(stdout, "ReplayTest: Warning - skipping cleanup of the replay " + "server\n"); + print_test_location(stdout, "ReplayTest: "); + DBUG_VOID_RETURN; + } + + /* + The databases that were not there when we took the baseline. The names + are borrowed from cur_dbs, and since cur_dbs is sorted, so is extra_dbs + - the loop below bsearch()es it. + */ + my_init_dynamic_array(PSI_NOT_INSTRUMENTED, &extra_dbs, sizeof(char*), + 16, 16, MYF(0)); + for (i= 0; i < cur_dbs.elements; i++) + { + char **db= (char**) dynamic_array_ptr(&cur_dbs, i); + if (!bsearch(db, replay_baseline_dbs.buffer, + replay_baseline_dbs.elements, sizeof(char*), + cmp_replay_string) && + insert_dynamic(&extra_dbs, db)) + { + /* Out of memory. The tables of this database are dropped one by one + below, only the empty database itself stays behind. */ + fprintf(stdout, "ReplayTest: Warning - out of memory, database '%s' " + "is left on the replay server\n", *db); + } + } + + /* + The script may have set foreign_key_checks back to 1, and the objects + are dropped in no particular order. + */ + replay_exec_cleanup_query(STRING_WITH_LEN("SET foreign_key_checks=0")); + + init_dynamic_string(&stmt, "", 256, 256); + + /* + Collect the tables and views that are not in the baseline into one + comma-separated list. The ones that sit in a database that is about to + go away are left to DROP DATABASE. + */ + for (i= 0; i < cur_objects.elements; i++) + { + struct st_replay_object *obj= + (struct st_replay_object*) dynamic_array_ptr(&cur_objects, i); + + if (bsearch(obj, replay_baseline_objects.buffer, + replay_baseline_objects.elements, + sizeof(struct st_replay_object), cmp_replay_object) || + bsearch(&obj->db, extra_dbs.buffer, extra_dbs.elements, + sizeof(char*), cmp_replay_string)) + continue; + + if (dropped_objects++) + dynstr_append_mem(&stmt, ", ", 2); + append_quoted_ident(&stmt, obj->db); + dynstr_append_mem(&stmt, ".", 1); + append_quoted_ident(&stmt, obj->name); + } + + if (dropped_objects) + { + /* + We did not ask the server which of these are views and which are + tables - that would have made the snapshot open every .frm - so try + both, the same way the replay script itself does. With IF EXISTS a + name of the wrong kind only produces a warning, so one statement per + kind is enough for the whole list. + */ + DYNAMIC_STRING drop; + init_dynamic_string(&drop, "DROP VIEW IF EXISTS ", stmt.length + 32, 128); + dynstr_append_mem(&drop, stmt.str, stmt.length); + replay_exec_cleanup_query(drop.str, drop.length); + + dynstr_set(&drop, "DROP TABLE IF EXISTS "); + dynstr_append_mem(&drop, stmt.str, stmt.length); + replay_exec_cleanup_query(drop.str, drop.length); + dynstr_free(&drop); + } + + for (i= 0; i < extra_dbs.elements; i++) + { + dynstr_set(&stmt, "DROP DATABASE IF EXISTS "); + append_quoted_ident(&stmt, *(char**) dynamic_array_ptr(&extra_dbs, i)); + replay_exec_cleanup_query(stmt.str, stmt.length); + } + + if (dropped_objects || extra_dbs.elements) + verbose_msg("ReplayTest: cleanup dropped %u table(s)/view(s) and " + "%lu database(s)", + dropped_objects, (ulong) extra_dbs.elements); + + /* + The script leaves the whole recorded context in @opt_context. It is + large and it stays in the connection's memory until the next script + overwrites it. + */ + replay_exec_cleanup_query(STRING_WITH_LEN("SET " REPLAY_CONTEXT_USERVAR + "=NULL")); + + /* + The script's USE may have selected a database that we have just dropped. + Get back to a database that exists: run_explain_directly_on_replay() + runs its EXPLAIN with whatever default database is current. + */ + replay_exec_cleanup_query(STRING_WITH_LEN("USE " REPLAY_DEFAULT_DB)); + + dynstr_free(&stmt); + delete_dynamic(&extra_dbs); + free_replay_snapshot(&cur_dbs, &cur_objects); + DBUG_VOID_RETURN; +} + + +/* TRUE if the `tok_len` bytes at `tok` are exactly the keyword `word` */ + +static my_bool replay_token_eq(const char *tok, size_t tok_len, + const LEX_CSTRING *word) +{ + return tok_len == word->length && !strncmp(tok, word->str, tok_len); +} + + +/* + Handle the argument of the "disable_replay " command. + + Syntax: + disable_replay next_query + disable_replay testfile + + The first token of `arg` must be "next_query" or "testfile". Everything + after the scope token is the reason string (spaces allowed). + + - "next_query": one-shot; the next SQL query executed via run_query_normal() + bypasses replay-server processing (if it is EXPLAIN). The flag is consumed + by that one query regardless of whether it is EXPLAIN. + - "testfile": sticky; disables replay-server processing for every EXPLAIN + until mysqltest exits. + + Any syntax violation is a hard error for the caller: the message to die() + with is returned, NULL means success. +*/ +const char *replay_do_disable(const char *arg, const char *end) +{ + const char *p= arg; + const char *tok; + size_t tok_len; + size_t reason_len; + my_bool is_testfile; + DBUG_ENTER("replay_do_disable"); + + /* Skip leading whitespace */ + while (p < end && my_isspace(charset_info, *p)) + p++; + + tok= p; + while (p < end && !my_isspace(charset_info, *p)) + p++; + tok_len= (size_t)(p - tok); + + if (replay_token_eq(tok, tok_len, &replay_scope_next_query)) + is_testfile= FALSE; + else if (replay_token_eq(tok, tok_len, &replay_scope_testfile)) + is_testfile= TRUE; + else + DBUG_RETURN(DISABLE_REPLAY_SYNTAX); + + /* Skip whitespace between the scope token and the reason */ + while (p < end && my_isspace(charset_info, *p)) + p++; + + if (p >= end) + DBUG_RETURN(DISABLE_REPLAY_SYNTAX " (reason missing)"); + + /* The reason is everything that is left, minus trailing whitespace */ + reason_len= (size_t)(end - p); + while (reason_len > 0 && + my_isspace(charset_info, p[reason_len - 1])) + reason_len--; + + if (is_testfile) + { + /* Sticky, and the reason is of no interest past this message */ + disable_replay_testfile= TRUE; + verbose_msg("disable_replay: replay disabled for the rest of this test " + "file (reason: %.*s)", (int) reason_len, p); + } + else + { + /* One-shot: the reason is kept for the query that consumes the flag */ + if (reason_len >= sizeof(disable_replay_reason)) + reason_len= sizeof(disable_replay_reason) - 1; + memcpy(disable_replay_reason, p, reason_len); + disable_replay_reason[reason_len]= '\0'; + disable_replay_next_query= TRUE; + verbose_msg("disable_replay: next query will bypass replay server " + "(reason: %s)", disable_replay_reason); + } + + DBUG_RETURN(NULL); +} + + +/* + Run one statement of a replay script on the replay server. + + `want_output` makes the output of the statement's result sets - table + headings and rows - be appended to `out`; the statements of a script that + are only executed for their side effects pass FALSE. `is_explain` marks + the EXPLAIN the test is actually after: its warnings are appended too and, + under REPLAY_SERVER_TRACE, its optimizer_trace is captured before the + cleanup queries can overwrite it. + + Returns FALSE when the statement failed; the error is then in `out` and the + rest of the script is not to be run. +*/ +static my_bool replay_run_stmt(const char *query, size_t query_len, + my_bool is_explain, my_bool want_output, + DYNAMIC_STRING *out) +{ + log_replay_query(query, query_len); + + if (mysql_real_query(replay_server_mysql, query, (ulong) query_len)) + { + char buf[512]; + size_t len= my_snprintf(buf, sizeof(buf), + "ReplayTest: Query error: %.*s: %s\n", + (int) query_len, query, + mysql_error(replay_server_mysql)); + fputs(buf, stdout); + print_test_location(stdout, "ReplayTest: "); + dynstr_append_mem(out, buf, len); + return FALSE; + } + + do + { + MYSQL_RES *res= mysql_store_result(replay_server_mysql); + if (res) + { + if (want_output) + { + if (!display_result_vertically) + append_table_headings(out, mysql_fetch_fields(res), + mysql_num_fields(res)); + append_result(out, res); + } + mysql_free_result(res); + } + if (mysql_errno(replay_server_mysql)) + { + char buf[512]; + size_t len= my_snprintf(buf, sizeof(buf), + "ReplayTest: Query error: %.*s: %s\n", + (int) query_len, query, + mysql_error(replay_server_mysql)); + fputs(buf, stdout); + if (want_output) + dynstr_append_mem(out, buf, len); + } + } while (mysql_next_result(replay_server_mysql) == 0); + + if (is_explain) + { + replay_append_warnings(replay_server_mysql, out); + /* + REPLAY_SERVER_TRACE: grab the trace now - the cleanup queries that + follow the script would overwrite it. Whether it is ever written to + disk is decided by replay_hook_result(). + */ + if (replay_trace_capture_buf) + capture_optimizer_trace(replay_server_mysql, replay_trace_capture_buf); + } + return TRUE; +} + + +/* + Execute the statements of a recorded context script on the replay server. + + The statements are separated by replay_stmt_separator. The script is run + up to and including its first EXPLAIN, whose output is what ends up in + `ds`; a script without an EXPLAIN contributes the output of its last + statement instead. +*/ +static void execute_replay_queries(const char *sql_script, DYNAMIC_STRING *ds) +{ + DYNAMIC_STRING result; + const char *stmt= sql_script; + my_bool found_explain= FALSE; + DBUG_ENTER("execute_replay_queries"); + + verbose_msg("ReplayTest: SQL script from optimizer_context:\n%s", sql_script); + + log_replay_session_start(); + + init_dynamic_string(&result, "", 1024, 1024); + + for (;;) + { + const char *sep= strstr(stmt, replay_stmt_separator); + size_t stmt_len= sep ? (size_t) (sep - stmt) : strlen(stmt); + const char *q= stmt, *q_end= stmt + stmt_len; + + /* Skip leading whitespace, and skip the statement if that is all it is */ + while (q < q_end && my_isspace(charset_info, *q)) + q++; + + if (q < q_end) + { + my_bool is_explain= is_explain_query(stmt, stmt_len); + /* + The output the test is after is the EXPLAIN's. Should the script have + none, the last statement's output is used instead. + */ + my_bool want_output= is_explain || !sep; + + verbose_msg("ReplayTest: Executing query on replay server (%s): %.*s", + is_explain ? "EXPLAIN - will stop after this" : + sep ? "intermediate" : "last query", + (int) stmt_len, stmt); + + if (!replay_run_stmt(stmt, stmt_len, is_explain, want_output, &result)) + goto cleanup; + + if (is_explain) + { + found_explain= TRUE; + verbose_msg("ReplayTest: Found EXPLAIN, stopping script execution"); + break; + } + } + + if (!sep) + break; + stmt= sep + REPLAY_STMT_SEPARATOR_LEN; + } + + if (!found_explain) + verbose_msg("ReplayTest: Warning - no EXPLAIN FORMAT=JSON found in script"); + +cleanup: + /* Preserve accumulated output (EXPLAIN / last-query) in ds BEFORE cleanup query */ + dynstr_append_mem(ds, result.str, result.length); + dynstr_free(&result); + + /* Reset optimizer_replay_context on the replay server, regardless of errors. + Drain and discard any output so ds is not affected. */ + if (replay_server_mysql) + { + if (mysql_real_query(replay_server_mysql, + STRING_WITH_LEN("SET " REPLAY_CONTEXT_SYSVAR "=''"))) + { + fprintf(stdout, "ReplayTest: Warning - failed to reset %s: %d %s\n", + REPLAY_CONTEXT_SYSVAR, + mysql_errno(replay_server_mysql), + mysql_error(replay_server_mysql)); + } + else + replay_drain_results(); + + /* Drop what this script has created, so that the next replay run starts + from the same state as this one did. */ + if (replay_cleanup && replay_baseline_valid) + replay_restore_baseline(); + } + + DBUG_VOID_RETURN; +} + + +/* + Run an EXPLAIN query directly on the replay server (no context replay), + appending its output (headings + rows + warnings) to ds. + + This is the fallback used when the test server produced an empty + optimizer_context for an EXPLAIN query. +*/ +static void run_explain_directly_on_replay(const char *query, size_t query_len, + DYNAMIC_STRING *ds) +{ + DBUG_ENTER("run_explain_directly_on_replay"); + + if (ensure_replay_server_connection() != 0) + { + fprintf(stdout, "ReplayTest: Failed to connect to replay server\n"); + DBUG_VOID_RETURN; + } + + (void) replay_run_stmt(query, query_len, TRUE, TRUE, ds); + DBUG_VOID_RETURN; +} + + +/* + Run one statement on the test server and discard whatever it returns. + + Used for the SET statements that bracket a replayed EXPLAIN. Returns TRUE + on error; `errmsg` is then printed if it is not NULL - pass NULL for the + statements whose failure we deliberately ignore. +*/ +static my_bool replay_exec_on_test_server(MYSQL *mysql, const char *stmt, + size_t len, const char *errmsg) +{ + MYSQL_RES *res; + if (mysql_real_query(mysql, stmt, (ulong) len)) + { + if (errmsg) + fprintf(stdout, "ReplayTest: %s: %d %s\n", errmsg, + mysql_errno(mysql), mysql_error(mysql)); + return TRUE; + } + if ((res= mysql_store_result(mysql))) + mysql_free_result(res); + return FALSE; +} + + +/* + Undo on the test server what replay_hook_pre_query() set up for this + EXPLAIN: stop recording optimizer contexts and put optimizer_trace back to + the value saved in @mtr_save_opt_trace. +*/ +void replay_undo_test_server_setup(MYSQL *mysql) +{ + replay_exec_on_test_server(mysql, + STRING_WITH_LEN("SET " REPLAY_RECORD_SYSVAR "=0"), + NULL); + if (replay_server_trace) + replay_exec_on_test_server(mysql, + STRING_WITH_LEN("SET optimizer_trace=" + REPLAY_SAVED_TRACE_USERVAR), + NULL); +} + + +/* + REPLAY_SERVER_TRACE: grab the test server's optimizer_trace for the EXPLAIN + that has just run and arm the replay-side capture buffer, which is what + makes replay_run_stmt() capture the replay server's trace as well. + + Must be called before anything else runs on the test server's connection, + as that would overwrite the trace we are after. A no-op, capture buffer + included, unless REPLAY_SERVER_TRACE is on. +*/ +static void replay_arm_trace_capture(MYSQL *mysql, + DYNAMIC_STRING *orig_trace, + DYNAMIC_STRING *replay_trace) +{ + if (replay_server_trace) + { + capture_optimizer_trace(mysql, orig_trace); + replay_trace_capture_buf= replay_trace; + } +} + + +/* + Collect the test server's own EXPLAIN output - headings, rows and warnings - + into `ds`, formatted the way the replay side formats it so that the two can + be compared. Consumes *res. +*/ +static void replay_collect_primary_explain(MYSQL *mysql, MYSQL_RES **res, + MYSQL_FIELD *fields, + uint num_fields, + DYNAMIC_STRING *ds) +{ + if (!display_result_vertically) + append_table_headings(ds, fields, num_fields); + append_result(ds, *res); + mysql_free_result(*res); + *res= 0; + + replay_append_warnings(mysql, ds); +} + + +enum replay_context_status +{ + REPLAY_CONTEXT_FOUND, /* a context script was recorded, `script` has it */ + REPLAY_CONTEXT_EMPTY, /* the EXPLAIN recorded no context */ + REPLAY_CONTEXT_ERROR /* the context could not be read at all */ +}; + + +/* + Read the optimizer context that the test server recorded for the EXPLAIN + that has just run, into `script`. +*/ +static enum replay_context_status replay_fetch_context(MYSQL *mysql, + DYNAMIC_STRING *script) +{ + MYSQL_RES *res; + MYSQL_ROW row; + enum replay_context_status status= REPLAY_CONTEXT_EMPTY; + DBUG_ENTER("replay_fetch_context"); + + verbose_msg("ReplayTest: Loading context"); + if (mysql_real_query(mysql, STRING_WITH_LEN("SELECT context FROM " + REPLAY_IS_CONTEXT_TABLE))) + { + fprintf(stdout, "ReplayTest: Failed to query %s: %d %s\n", + REPLAY_IS_CONTEXT_TABLE, mysql_errno(mysql), mysql_error(mysql)); + DBUG_RETURN(REPLAY_CONTEXT_ERROR); + } + + if (!(res= mysql_store_result(mysql))) + DBUG_RETURN(REPLAY_CONTEXT_EMPTY); + + if (mysql_num_rows(res) > 0 && (row= mysql_fetch_row(res)) && row[0]) + { + dynstr_set(script, row[0]); + status= REPLAY_CONTEXT_FOUND; + } + mysql_free_result(res); + DBUG_RETURN(status); +} + + +/* + If the recorded context contains a marker that we know cannot be replayed + correctly, return that marker, otherwise NULL. +*/ +static const char *replay_context_stop_word(const char *script) +{ + for (const char **sw= replay_context_stop_words; *sw; sw++) + if (strstr(script, *sw)) + return *sw; + return NULL; +} + + +static my_bool replay_explains_match(const DYNAMIC_STRING *a, + const DYNAMIC_STRING *b) +{ + return a->length == b->length && memcmp(a->str, b->str, a->length) == 0; +} + + +/* + Produce, in `ds_replay`, the EXPLAIN output of the replay server for the + EXPLAIN that has just run on the test server. + + ds_primary the test server's own output, used as the fallback and as + the comparison base for the trace dump + ds_orig_trace, + ds_replay_trace receive the optimizer traces of the two servers, but only + when REPLAY_SERVER_TRACE is on + + On return the test server is back in the state it was in before + replay_hook_pre_query() ran. +*/ +static void replay_explain_on_replay_server(MYSQL *mysql, const char *query, + size_t query_len, + const DYNAMIC_STRING *ds_primary, + DYNAMIC_STRING *ds_replay, + DYNAMIC_STRING *ds_orig_trace, + DYNAMIC_STRING *ds_replay_trace) +{ + DYNAMIC_STRING script; + enum replay_context_status status; + DBUG_ENTER("replay_explain_on_replay_server"); + + init_dynamic_string(&script, "", 1024, 1024); + status= replay_fetch_context(mysql, &script); + + /* + We could not read the context, so there is nothing to replay: leave + `ds_replay` empty and let the test fail on the missing EXPLAIN output + rather than pass on the test server's result. + */ + if (status == REPLAY_CONTEXT_ERROR) + goto done; + + if (status == REPLAY_CONTEXT_FOUND) + { + const char *stop_word= replay_context_stop_word(script.str); + if (stop_word) + { + /* Something we know we cannot replay: report the test server's result */ + verbose_msg("ReplayTest: stop word '%s' found in optimizer_context, " + "using primary EXPLAIN result", stop_word); + dynstr_append_mem(ds_replay, ds_primary->str, ds_primary->length); + goto done; + } + } + + replay_arm_trace_capture(mysql, ds_orig_trace, ds_replay_trace); + + if (status == REPLAY_CONTEXT_FOUND) + { + if (ensure_replay_server_connection() == 0) + execute_replay_queries(script.str, ds_replay); + else + fprintf(stdout, "ReplayTest: Failed to connect to replay server\n"); + } + else + { + /* No context recorded: run the EXPLAIN on the replay server as it is. */ + verbose_msg("ReplayTest: empty optimizer_context, running EXPLAIN " + "directly on replay server"); + run_explain_directly_on_replay(query, query_len, ds_replay); + } + replay_trace_capture_buf= NULL; + +done: + replay_undo_test_server_setup(mysql); + dynstr_free(&script); + DBUG_VOID_RETURN; +} + + +/* + Consume the one-shot "disable_replay next_query" flag. It applies to exactly + one query executed through run_query_normal(), whether or not it is an + EXPLAIN. Returns TRUE if this query is the one it applies to. +*/ +static my_bool replay_consume_disable_next_query() +{ + if (!disable_replay_next_query) + return FALSE; + verbose_msg("ReplayTest: replay disabled for this query (reason: %s)", + disable_replay_reason); + disable_replay_next_query= FALSE; + disable_replay_reason[0]= '\0'; + return TRUE; +} + + +/* + Pre-query hook of the replay-server mode. See mysqltest_replay_server.h. +*/ +my_bool replay_hook_pre_query(MYSQL *mysql, my_bool complete_query, + const char *query, size_t query_len) +{ + DBUG_ENTER("replay_hook_pre_query"); + my_bool disabled= replay_consume_disable_next_query(); + + if (!replay_server_socket || disabled || disable_replay_testfile || + !complete_query || !is_explain_query(query, query_len)) + DBUG_RETURN(FALSE); + + verbose_msg("ReplayTest: Detected EXPLAIN FORMAT=JSON query, " + "activating replay mode"); + + /* + Clear any context left over from an earlier query (e.g. a prior EXPLAIN + whose context must not leak into this one), then record ours. + */ + replay_exec_on_test_server(mysql, + STRING_WITH_LEN("SET " REPLAY_RECORD_SYSVAR "=0"), + NULL); + if (replay_exec_on_test_server(mysql, + STRING_WITH_LEN("SET " REPLAY_RECORD_SYSVAR "=1"), + "Failed to set " REPLAY_RECORD_SYSVAR)) + DBUG_RETURN(FALSE); + + /* + REPLAY_SERVER_TRACE: enable optimizer_trace on the test server too, saving + its current value into REPLAY_SAVED_TRACE_USERVAR so that + replay_undo_test_server_setup() can restore it. The replay side is enabled + once per connection in ensure_replay_server_connection(). + */ + if (replay_server_trace) + replay_exec_on_test_server(mysql, + STRING_WITH_LEN("SET " REPLAY_SAVED_TRACE_USERVAR + "=@@optimizer_trace, " + "optimizer_trace='enabled=on'"), + "Warning - failed to enable optimizer_trace on test server"); + + DBUG_RETURN(TRUE); +} + + +/* + Result hook of the replay-server mode: replace the EXPLAIN result of the test + server with the one the replay server produces from the recorded optimizer + context, and, under REPLAY_SERVER_TRACE, dump both optimizer traces when the + two EXPLAINs disagree. +*/ +void replay_hook_result(MYSQL *mysql, MYSQL_RES **res, + MYSQL_FIELD *fields, uint num_fields, + const char *query, size_t query_len, + DYNAMIC_STRING *ds) +{ + DYNAMIC_STRING ds_primary, ds_replay, ds_orig_trace, ds_replay_trace; + DBUG_ENTER("replay_hook_result"); + + init_dynamic_string(&ds_primary, "", 1024, 1024); + init_dynamic_string(&ds_replay, "", 1024, 1024); + /* The trace buffers are only written when REPLAY_SERVER_TRACE is on */ + if (replay_server_trace) + { + init_dynamic_string(&ds_orig_trace, "", 1024, 1024); + init_dynamic_string(&ds_replay_trace, "", 1024, 1024); + } + + replay_collect_primary_explain(mysql, res, fields, num_fields, &ds_primary); + replay_explain_on_replay_server(mysql, query, query_len, &ds_primary, + &ds_replay, &ds_orig_trace, + &ds_replay_trace); + + /* The replay-side output is what the test sees. */ + dynstr_append_mem(ds, ds_replay.str, ds_replay.length); + + /* + REPLAY_SERVER_TRACE: the traces are only worth keeping when the two + EXPLAINs (rows + warnings) differ. + */ + if (replay_server_trace && !replay_explains_match(&ds_primary, &ds_replay)) + { + flush_trace_block(&replay_opt_trace_original_file, + replay_opt_trace_original_path, + query, query_len, &ds_orig_trace); + flush_trace_block(&replay_opt_trace_replay_file, + replay_opt_trace_replay_path, + query, query_len, &ds_replay_trace); + } + + dynstr_free(&ds_primary); + dynstr_free(&ds_replay); + if (replay_server_trace) + { + dynstr_free(&ds_orig_trace); + dynstr_free(&ds_replay_trace); + } + DBUG_VOID_RETURN; +} + + +/* + Set up replay-server mode from the environment. Does nothing at all unless + REPLAY_SERVER_SOCKET names a replay server, which is the ordinary case. +*/ +void replay_init(const char *result_file_name) +{ + const char *vardir; + const char *no_cleanup_env; + const char *trace_env; + DBUG_ENTER("replay_init"); + + replay_server_socket= getenv(REPLAY_ENV_SOCKET); + if (!replay_server_socket || !replay_server_socket[0]) + DBUG_VOID_RETURN; + + verbose_msg("ReplayTest mode enabled, replay server socket: %s", + replay_server_socket); + + /* Initialize replay query log file */ + if ((vardir= getenv("MYSQLTEST_VARDIR"))) + { + char log_path[FN_REFLEN]; + my_snprintf(log_path, sizeof(log_path), "%s" REPLAY_QUERY_LOG_SUBPATH, + vardir); + /* Use append mode - MTR cleans var directory on each run */ + replay_log_file= fopen(log_path, "a"); + if (!replay_log_file) + fprintf(stderr, "Warning: Could not open replay log file: %s\n", + log_path); + else + verbose_msg("ReplayTest: Logging queries to %s", log_path); + } + + /* REPLAY_ENV_NO_CLEANUP: keep whatever the replay scripts create on + the replay server, for post-mortem inspection. */ + no_cleanup_env= getenv(REPLAY_ENV_NO_CLEANUP); + if (no_cleanup_env && no_cleanup_env[0]) + { + replay_cleanup= FALSE; + fprintf(stderr, "mysqltest: %s is ON, the replay server will not be " + "cleaned up between runs\n", REPLAY_ENV_NO_CLEANUP); + } + + /* REPLAY_ENV_TRACE: also dump optimizer_trace from both servers. */ + trace_env= getenv(REPLAY_ENV_TRACE); + if (trace_env && trace_env[0]) + { + replay_server_trace= TRUE; + fprintf(stderr, "mysqltest: %s is ON\n", REPLAY_ENV_TRACE); + if (result_file_name) + { + char buf[FN_REFLEN]; + fn_format(buf, result_file_name, "", REPLAY_TRACE_EXT_ORIGINAL, + MY_REPLACE_EXT); + replay_opt_trace_original_path= + my_strdup(PSI_NOT_INSTRUMENTED, buf, MYF(MY_WME)); + fn_format(buf, result_file_name, "", REPLAY_TRACE_EXT_REPLAY, + MY_REPLACE_EXT); + replay_opt_trace_replay_path= + my_strdup(PSI_NOT_INSTRUMENTED, buf, MYF(MY_WME)); + /* Start each test run with a fresh trace dump; the files are + re-created lazily by flush_trace_block() if and when an EXPLAIN + diverges. */ + (void) my_delete(replay_opt_trace_original_path, MYF(0)); + (void) my_delete(replay_opt_trace_replay_path, MYF(0)); + verbose_msg("ReplayTest: optimizer_trace dumps -> %s , %s", + replay_opt_trace_original_path, + replay_opt_trace_replay_path); + fprintf(stderr, "mysqltest: %s: %s\n", REPLAY_ENV_TRACE, + replay_opt_trace_original_path); + } + else + { + fprintf(stderr, + "Warning: %s is set but no --result-file was given; " + "optimizer_trace dumps will be skipped.\n", REPLAY_ENV_TRACE); + } + } + DBUG_VOID_RETURN; +} + + +/* + Close the connection to the replay server and release everything + replay_init() and the replay run allocated. +*/ +void replay_free(void) +{ + DBUG_ENTER("replay_free"); + + if (replay_server_mysql) + { + mysql_close(replay_server_mysql); + replay_server_mysql= NULL; + } + + if (replay_baseline_valid) + { + free_replay_snapshot(&replay_baseline_dbs, &replay_baseline_objects); + replay_baseline_valid= FALSE; + } + + if (replay_log_file) + { + fclose(replay_log_file); + replay_log_file= NULL; + } + + if (replay_opt_trace_original_file) + { + fclose(replay_opt_trace_original_file); + replay_opt_trace_original_file= NULL; + } + if (replay_opt_trace_replay_file) + { + fclose(replay_opt_trace_replay_file); + replay_opt_trace_replay_file= NULL; + } + if (replay_opt_trace_original_path) + { + my_free((void*)replay_opt_trace_original_path); + replay_opt_trace_original_path= NULL; + } + if (replay_opt_trace_replay_path) + { + my_free((void*)replay_opt_trace_replay_path); + replay_opt_trace_replay_path= NULL; + } + DBUG_VOID_RETURN; +} diff --git a/client/mysqltest_replay_server.h b/client/mysqltest_replay_server.h new file mode 100644 index 0000000000000..09f172bba2c35 --- /dev/null +++ b/client/mysqltest_replay_server.h @@ -0,0 +1,106 @@ +/* Copyright (c) 2026, MariaDB plc + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; version 2 of the License. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1335 USA */ + +#ifndef MYSQLTEST_REPLAY_SERVER_INCLUDED +#define MYSQLTEST_REPLAY_SERVER_INCLUDED + +/* + Replay-server mode of mysqltest, the client half of mtr --replay-server. + + When mariadb-test-run.pl starts a second ("replay") server it passes its + socket in REPLAY_SERVER_SOCKET. mysqltest then makes the test server record + the optimizer context of every EXPLAIN, replays that context on the replay + server and puts the replay server's EXPLAIN output into the test result in + place of the test server's own. Everything that implements this lives in + mysqltest_replay_server.cc; mysqltest.cc reaches it through the entry points + below. + + Include after client_priv.h - MYSQL, DYNAMIC_STRING & co. are assumed known. +*/ + +#include + +/* -- Entry points implemented in mysqltest_replay_server.cc -------------- */ + +/* + Set up replay-server mode from the environment (REPLAY_SERVER_SOCKET and + friends). A no-op when REPLAY_SERVER_SOCKET is not set, which is the + ordinary case. `result_file_name` is --result-file, or NULL; it gives the + optimizer_trace dumps their names. +*/ +void replay_init(const char *result_file_name); + +/* Close the replay connection and release everything replay_init() set up */ +void replay_free(void); + +/* + Argument handler of the "disable_replay " command. + `arg` .. `end` is the argument text. + Returns NULL on success, or the message to die() with on a syntax error. +*/ +const char *replay_do_disable(const char *arg, const char *end); + +/* + Pre-query hook: decide whether this query is to be replayed and, if it is, + make the test server record its optimizer context. Called once per query - + also for queries that are not replayed, so that one-shot flags such as + "disable_replay next_query" are consumed exactly once. + + `complete_query` says whether the query is both sent and reaped by this + call; only such a query can have its result set replaced. + + Returns TRUE when the hook is active for this query, that is, when its first + result set is to be replaced with the replay server's output. +*/ +my_bool replay_hook_pre_query(MYSQL *mysql, my_bool complete_query, + const char *query, size_t query_len); + +/* + Result hook: replace the EXPLAIN result of the test server with the one the + replay server produces from the recorded optimizer context. Consumes *res. +*/ +void replay_hook_result(MYSQL *mysql, MYSQL_RES **res, MYSQL_FIELD *fields, + uint num_fields, const char *query, size_t query_len, + DYNAMIC_STRING *ds); + +/* + Undo on the test server what replay_hook_pre_query() set up. Called by + replay_hook_result(), and directly by mysqltest.cc when a query armed by + replay_hook_pre_query() never produced a result set. +*/ +void replay_undo_test_server_setup(MYSQL *mysql); + +/* -- Shared functions defined in mysqltest.cc ---------------------------- */ + +void verbose_msg(const char *fmt, ...) ATTRIBUTE_FORMAT(printf, 1, 2); +void append_field(DYNAMIC_STRING *ds, uint col_idx, MYSQL_FIELD *field, + char *val, size_t len, my_bool is_null); +void append_table_headings(DYNAMIC_STRING *ds, MYSQL_FIELD *field, + uint num_fields); +void append_result(DYNAMIC_STRING *ds, MYSQL_RES *res); +int append_warnings(DYNAMIC_STRING *ds, MYSQL *mysql); + +/* Current settings of the running mysqltest */ +extern CHARSET_INFO *charset_info; /* the charset input is parsed in */ +extern my_bool disable_warnings; +extern my_bool display_result_vertically; + +/* TRUE for "EXPLAIN ..." queries that the replay server can handle */ +my_bool is_explain_query(const char *query, size_t query_len); + +/* Print the current test-file location, each line prefixed with `prefix` */ +void print_test_location(FILE *f, const char *prefix); + +#endif /* MYSQLTEST_REPLAY_SERVER_INCLUDED */ diff --git a/mysql-test/EXTRA_SERVER_QUICKSTART.txt b/mysql-test/EXTRA_SERVER_QUICKSTART.txt new file mode 100644 index 0000000000000..7a09487e8b9d2 --- /dev/null +++ b/mysql-test/EXTRA_SERVER_QUICKSTART.txt @@ -0,0 +1,89 @@ +================================================================================ +EXTRA SERVER QUICK START GUIDE +================================================================================ + +This feature allows you to start additional MariaDB server instances during +test execution while mysql-test-run is already running. + +BASIC USAGE +----------- + +1. In your test file (.test): + + # Start extra server + --let $extra_server_num= 1 + --source include/start_extra_server.inc + + # Connect to it + --connect (extra1, 127.0.0.1, root, , test, $EXTRA_SERVER_PORT) + CREATE TABLE t1 (id INT); + SELECT * FROM t1; + + # Stop it + --disconnect extra1 + --let $extra_server_num= 1 + --source include/stop_extra_server.inc + +2. Run the test: + + cd mysql-test + ./mysql-test-run main.extra_server_example # rename off .DISABLED first + +WHAT IT DOES +------------ + +✓ Creates new data directory: var/extra_server_N/data (copied from install.db) +✓ Picks non-conflicting port from the top of the run's port group +✓ Creates unique socket: var/tmp/extra_server_N.sock +✓ Starts mysqld with --skip-grant-tables (no password needed) +✓ Exports connection info: $EXTRA_SERVER_PORT, $EXTRA_SERVER_SOCKET, etc. + +AVAILABLE VARIABLES AFTER START +-------------------------------- + +$EXTRA_SERVER_PORT - Port number (e.g., 10011 for server 1) +$EXTRA_SERVER_SOCKET - Socket path +$EXTRA_SERVER_DATADIR - Data directory path +$EXTRA_SERVER_PID - Process ID + +CUSTOM PORT/SOCKET +------------------ + +--let $extra_server_num= 2 +--let $extra_server_port= 15000 +--let $extra_server_socket= /tmp/my_custom.sock +--source include/start_extra_server.inc + +MULTIPLE SERVERS +---------------- + +--let $extra_server_num= 1 +--source include/start_extra_server.inc + +--let $extra_server_num= 2 +--source include/start_extra_server.inc + +# Now you have two extra servers running! + +FILES CREATED +------------- + +lib/start_extra_server.pl - Perl script (main implementation) +include/start_extra_server.inc - Test include to start server +include/stop_extra_server.inc - Test include to stop server +main/extra_server_example.test.DISABLED - Example test +lib/EXTRA_SERVER_README.md - Full documentation + +TROUBLESHOOTING +--------------- + +If server fails to start, check: + var/log/extra_server_N.err + +Connection info is stored in: + var/tmp/extra_server_N.info + +Direct invocation (from Perl or shell): + perl lib/start_extra_server.pl [port] [socket] + +================================================================================ diff --git a/mysql-test/include/start_extra_server.inc b/mysql-test/include/start_extra_server.inc new file mode 100644 index 0000000000000..78705b5acbec5 --- /dev/null +++ b/mysql-test/include/start_extra_server.inc @@ -0,0 +1,107 @@ +# ==== Purpose ==== +# +# Start an additional mysqld server instance while mysql-test-run is running. +# This creates a new data directory (copied from var/install.db), picks a +# non-conflicting port and socket, and starts the server. +# +# ==== Usage ==== +# +# --let $extra_server_num= 1 +# [--let $extra_server_port= 13307] +# [--let $extra_server_socket= /path/to/socket] +# --source include/start_extra_server.inc +# +# After sourcing this file, the following mysqltest variables will be set: +# $EXTRA_SERVER_PORT - Port number of the extra server +# $EXTRA_SERVER_SOCKET - Socket path of the extra server +# $EXTRA_SERVER_DATADIR - Data directory of the extra server +# $EXTRA_SERVER_PID - Process ID of the extra server +# +# You can then connect to the server using: +# --connect (conn_name, 127.0.0.1, root, , test, $EXTRA_SERVER_PORT) +# +# ==== Parameters ==== +# +# $extra_server_num +# Required. A unique number identifying this extra server instance. +# Must be unique across all extra servers started in the same test. +# +# $extra_server_port +# Optional. Custom port number for the server. If not specified, the port +# is taken from the top of the run's port group - see extra_server_port() +# in lib/My/ExtraServer.pm. +# +# $extra_server_socket +# Optional. Custom socket path. If not specified, will use +# $MYSQLTEST_VARDIR/tmp/extra_server_N.sock +# + +if (!$extra_server_num) +{ + --die extra_server_num must be set before sourcing start_extra_server.inc +} + +--let $include_filename= start_extra_server.inc [server $extra_server_num] +--source include/begin_include_file.inc + +# Export mysqltest variables to environment for Perl +--let extra_server_num_env= $extra_server_num +--let extra_server_port_env= $extra_server_port +--let extra_server_socket_env= $extra_server_socket + +--perl + use strict; + use warnings; + use lib "$ENV{MYSQL_TEST_DIR}/lib"; + # Where the extra server keeps its files, and the format of the info file it + # hands back - see lib/My/ExtraServer.pm + use My::ExtraServer; + + my $server_num = $ENV{extra_server_num_env} or die "extra_server_num not set"; + my $vardir = $ENV{MYSQLTEST_VARDIR} or die "MYSQLTEST_VARDIR not set"; + my $port = $ENV{extra_server_port_env} || ""; + my $socket = $ENV{extra_server_socket_env} || ""; + + my $script = "$ENV{MYSQL_TEST_DIR}/lib/start_extra_server.pl"; + die "Script not found: $script\n" unless -f $script; + + # No shell: a vardir or socket path with a space in it stays one argument, + # and the empty placeholders keep $socket out of the port position. + my @cmd = ($^X, $script, $server_num, $port, $socket); + + # The script is chatty and prints volatile paths and pids, none of which + # belongs in a test result - keep its output back unless it fails. + my $out = ""; + if (open my $ph, '-|', @cmd) { + local $/; + $out = <$ph> // ""; + close $ph; + } + if ($?) { + print "Failed to start extra server $server_num:\n$out"; + die "start_extra_server.pl exited with status $?\n"; + } + + my %info = extra_server_read_info($vardir, $server_num); + unless (%info) { + print $out; + die "Info file not found: " . + extra_server_info_file($vardir, $server_num) . "\n"; + } + + # Hand the connection info back as mysqltest variables. --perl runs in a + # process of its own, so %ENV changes made here would be lost; the file of + # "let" statements written below is sourced by the caller instead. + open my $fh, '>', "$ENV{MYSQL_TMP_DIR}/extra_server_vars.inc" + or die "Cannot write $ENV{MYSQL_TMP_DIR}/extra_server_vars.inc: $!\n"; + print $fh "let \$EXTRA_SERVER_$_= $info{$_};\n" + for grep { defined $info{$_} } qw(PORT SOCKET DATADIR PID); + close $fh; + +EOF + +--source $MYSQL_TMP_DIR/extra_server_vars.inc +--remove_file $MYSQL_TMP_DIR/extra_server_vars.inc + +--let $include_filename= start_extra_server.inc [server $extra_server_num] +--source include/end_include_file.inc diff --git a/mysql-test/include/stop_extra_server.inc b/mysql-test/include/stop_extra_server.inc new file mode 100644 index 0000000000000..282daf076f543 --- /dev/null +++ b/mysql-test/include/stop_extra_server.inc @@ -0,0 +1,84 @@ +# ==== Purpose ==== +# +# Stop an extra server instance that was started with start_extra_server.inc +# +# ==== Usage ==== +# +# --let $extra_server_num= 1 +# --source include/stop_extra_server.inc +# +# ==== Parameters ==== +# +# $extra_server_num +# Required. The number of the extra server to stop (same number used +# when starting it with start_extra_server.inc). +# + +if (!$extra_server_num) +{ + --die extra_server_num must be set before sourcing stop_extra_server.inc +} + +--let $include_filename= stop_extra_server.inc [server $extra_server_num] +--source include/begin_include_file.inc + +# Export mysqltest variable to environment for Perl +--let extra_server_num_env= $extra_server_num + +--perl + use strict; + use warnings; + use lib "$ENV{MYSQL_TEST_DIR}/lib"; + # Where the extra server keeps its files, and the format of the info file + # start_extra_server.pl left behind - see lib/My/ExtraServer.pm + use My::ExtraServer; + + my $server_num = $ENV{extra_server_num_env} or die "extra_server_num not set"; + my $vardir = $ENV{MYSQLTEST_VARDIR} or die "MYSQLTEST_VARDIR not set"; + + my %info = extra_server_read_info($vardir, $server_num); + my $info_file = extra_server_info_file($vardir, $server_num); + unless (%info) { + print "Warning: no info file for extra server $server_num\n"; + print "Server may not be running or already stopped.\n"; + exit 0; + } + + my $pid = $info{PID}; + unless ($pid) { + print "Warning: no PID in the info file of extra server $server_num\n"; + unlink $info_file; + exit 0; + } + + # Nothing volatile - no pid, no paths - goes to stdout: this runs inside a + # test and everything printed here lands in its result. + + # The server is not a child of mysqltest, so waitpid() cannot poll it - + # "kill 0" (signal 0, an existence check) is used instead. + if (kill 0, $pid) { + kill 'TERM', $pid; + + my $max_wait = 10; + for my $waited (1 .. $max_wait) { + last unless kill(0, $pid); + sleep 1; + } + + if (kill 0, $pid) { + print "Server did not stop gracefully, sending SIGKILL\n"; + kill 'KILL', $pid; + sleep 1; + } + } + + # Cleanup files + unlink $info_file; + unlink $info{PID_FILE} if $info{PID_FILE}; + unlink $info{SOCKET} if $info{SOCKET} && -S $info{SOCKET}; + + print "Extra server $server_num stopped\n"; +EOF + +--let $include_filename= stop_extra_server.inc [server $extra_server_num] +--source include/end_include_file.inc diff --git a/mysql-test/lib/EXTRA_SERVER_README.md b/mysql-test/lib/EXTRA_SERVER_README.md new file mode 100644 index 0000000000000..7025274c22b00 --- /dev/null +++ b/mysql-test/lib/EXTRA_SERVER_README.md @@ -0,0 +1,135 @@ +# Extra Server Script for MySQL Test Framework + +## Overview + +This script allows you to dynamically start additional MariaDB server instances during test execution while `mysql-test-run` is already running. This is useful for testing scenarios that require multiple independent server instances. + +## Features + +- **Dynamic server creation**: Start servers on-demand during test execution +- **Automatic port allocation**: Non-conflicting ports (base_port + 10 + N) +- **Automatic socket allocation**: Unique socket paths per server +- **Data directory management**: Copies from existing `var/install.db` +- **Connection info export**: Provides host, port, socket, datadir, PID + +## Files + +- `lib/start_extra_server.pl` - Perl script that starts the extra server +- `include/start_extra_server.inc` - Test include file to start server +- `include/stop_extra_server.inc` - Test include file to stop server +- `main/extra_server_example.test.DISABLED` - Example test demonstrating usage + +## Usage + +### Starting an Extra Server + +```sql +# Set the server number (must be unique) +--let $extra_server_num= 1 + +# Optional: specify custom port +# --let $extra_server_port= 13307 + +# Optional: specify custom socket +# --let $extra_server_socket= /path/to/socket + +# Start the server +--source include/start_extra_server.inc +``` + +After starting, the following variables are available: +- `$EXTRA_SERVER_PORT` - Port number +- `$EXTRA_SERVER_SOCKET` - Socket path +- `$EXTRA_SERVER_DATADIR` - Data directory +- `$EXTRA_SERVER_PID` - Process ID + +### Connecting to the Extra Server + +```sql +--connect (conn_name, 127.0.0.1, root, , test, $EXTRA_SERVER_PORT) +SELECT "Connected!" AS status; +# ... perform operations ... +--disconnect conn_name +``` + +### Stopping the Extra Server + +```sql +--let $extra_server_num= 1 +--source include/stop_extra_server.inc +``` + +## Port Allocation + +Ports are automatically calculated to avoid conflicts. `mariadb-test-run.pl` +hands out the ports of a run's group from the bottom upwards: +- Master servers: `base_port + 0`, `base_port + 1` +- Slave servers: `base_port + 2`, `base_port + 3`, `base_port + 4` + +An extra server therefore takes its port from the *top* of the group, so that +it is out of the way of a configuration that needs many ports: +- Extra servers: `base_port + group_size - server_num` + +The group size comes from `MTR_PORT_GROUP_SIZE` (30 by default), and +`base_port` is `MASTER_MYPORT`, or 10000 when that is unset. See +`extra_server_port()` in `lib/My/ExtraServer.pm`. + +## Data Directory + +The script copies `var/install.db` to `var/extra_server_N/data`, so: +- No bootstrap needed (system tables already exist) +- Fast startup +- Clean slate for each server + +## Server Configuration + +The command line is built by `extra_server_mysqld_args()` in +`lib/My/ExtraServer.pm` - see there for the current option set rather than a +copy of it here. Of note: the server runs with `--skip-grant-tables` (so +tests need no password) and with minimal memory settings. + +## Example Test + +See `main/extra_server_example.test.DISABLED` for a worked example. + +## Invocation from mysqltest.cc + +The script can be invoked using the `--exec` command in test files: + +```sql +--exec perl $MYSQL_TEST_DIR/lib/start_extra_server.pl 1 +``` + +Or more conveniently via the include files as shown above. + +## Troubleshooting + +### Server fails to start + +Check the log file: `var/log/extra_server_N.err` + +### Port conflicts + +Specify a custom port: +```sql +--let $extra_server_port= 15000 +``` + +### Connection issues + +Verify the server is running: +```sql +--exec ps aux | grep extra_server +``` + +Check the info file: +```sql +--exec cat $MYSQLTEST_VARDIR/tmp/extra_server_1.info +``` + +## Limitations + +- Servers run with `--skip-grant-tables` (no authentication) +- No automatic cleanup on test failure (use `--force` in mysql-test-run) +- The server is not one of the mysqlds `mariadb-test-run.pl` manages, so it + is not covered by its leftover-kill, crash detection or log checking diff --git a/mysql-test/lib/My/ExtraServer.pm b/mysql-test/lib/My/ExtraServer.pm new file mode 100644 index 0000000000000..c9b834830a607 --- /dev/null +++ b/mysql-test/lib/My/ExtraServer.pm @@ -0,0 +1,194 @@ +# -*- cperl -*- +# Copyright (c) 2026, MariaDB Corporation. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1335 USA + +package My::ExtraServer; + +# +# Where an "extra server" keeps its files and how it is started. +# +# An extra server is a mysqld instance next to the ones of the test run. +# mtr --replay-server uses extra server EXTRA_SERVER_NUM as the replay server: +# the second server that recorded optimizer contexts are replayed on. +# +# Everything below is shared by the two places that have to agree on it: +# lib/start_extra_server.pl, which starts the server, and +# mariadb-test-run.pl, which prints the very same command line in +# --replay-server-manual mode and later watches and removes those files. +# + +use strict; +use warnings; +use My::File::Path; # mkpath / rmtree / copytree, as the rest of mtr uses +use base qw(Exporter); + +our @EXPORT= qw(EXTRA_SERVER_NUM + extra_server_dir extra_server_datadir + extra_server_socket extra_server_default_socket + extra_server_pid_file extra_server_err_log + extra_server_general_log extra_server_info_file + extra_server_install_db extra_server_port + extra_server_mysqld_args extra_server_prepare_datadir + extra_server_write_info extra_server_read_info); + +# +# The replay server is extra server number 1. There is only ever one of it, +# which is why --replay-server refuses to run with --parallel > 1. +# +use constant EXTRA_SERVER_NUM => 1; + +# The base port to fall back on when the run has none (MASTER_MYPORT unset), +# and the size of a port group to assume when mtr has not told us +# (MTR_PORT_GROUP_SIZE) - mariadb-test-run.pl's own default. +use constant EXTRA_SERVER_BASE_PORT => 10000; +use constant EXTRA_SERVER_PORT_GROUP_SIZE => 30; + +# Everything of extra server $num lives under this directory ... +sub extra_server_dir { my ($vardir, $num)= @_; "$vardir/extra_server_$num" } +sub extra_server_datadir { extra_server_dir(@_) . "/data" } +sub extra_server_pid_file{ extra_server_dir(@_) . "/mysqld.pid" } + +# ... including the socket, which mtr --replay-server prefers over the default +# below because the tmp directory is cleaned up while tests run. +sub extra_server_socket { extra_server_dir(@_) . "/mysqld.sock" } + +# The socket start_extra_server.pl picks when it is not given one. +sub extra_server_default_socket +{ my ($vardir, $num)= @_; "$vardir/tmp/extra_server_$num.sock" } + +# The logs go where all other logs of the run go ... +sub extra_server_err_log +{ my ($vardir, $num)= @_; "$vardir/log/extra_server_$num.err" } +sub extra_server_general_log +{ my ($vardir, $num)= @_; "$vardir/log/extra_server_$num.log" } + +# ... and the connection info start_extra_server.pl hands back to its caller +# (HOST=, PORT=, SOCKET=, PID= ... one per line) goes to the tmp directory. +sub extra_server_info_file +{ my ($vardir, $num)= @_; "$vardir/tmp/extra_server_$num.info" } + +# The datadir template mysql_install_db() prepared for this run +sub extra_server_install_db { my ($vardir)= @_; "$vardir/install.db" } + +# +# The port of extra server $num, counted down from the top of the run's port +# group. My::ConfigFactory::fix_port() hands out the ports of a group from the +# bottom upwards, so a server sitting near the bottom is in the way of any +# configuration that needs more than a handful of ports. +# +sub extra_server_port +{ + my ($base_port, $num)= @_; + my $group_size= $ENV{MTR_PORT_GROUP_SIZE} || EXTRA_SERVER_PORT_GROUP_SIZE; + return ($base_port || EXTRA_SERVER_BASE_PORT) + $group_size - $num; +} + +# +# The command line of extra server $num. The binary comes first, the way both +# exec() and "gdb --args" want it; a caller that only needs the arguments +# takes a slice. +# +# $for_debugger adds mysqld's --gdb, which is wanted only when a debugger is +# really going to be attached: it clears TEST_CORE_ON_SIGNAL, so a server +# started with it leaves neither a core file nor a stack trace in its error +# log when it crashes - the very things wanted from an unattended run. +# +sub extra_server_mysqld_args +{ + my ($mysqld, $vardir, $num, $port, $socket, $for_debugger)= @_; + return ($mysqld, + "--no-defaults", + "--datadir=" . extra_server_datadir($vardir, $num), + "--port=$port", + "--socket=$socket", + "--pid-file=" . extra_server_pid_file($vardir, $num), + "--log-error=" . extra_server_err_log($vardir, $num), + "--general-log=1", + "--general-log-file=" . extra_server_general_log($vardir, $num), + "--skip-networking=0", + "--skip-grant-tables", + "--key-buffer-size=1M", + "--sort-buffer-size=256K", + "--max-heap-table-size=1M", + ($for_debugger ? ("--gdb") : ())); +} + +# +# Give extra server $num a fresh datadir, copied from the install.db of this +# run, and make sure the directories it writes to exist. Returns the datadir, +# dies if it cannot be prepared. $report, if given, is called with progress +# messages (print for a script, mtr_report for mtr). +# +sub extra_server_prepare_datadir +{ + my ($vardir, $num, $report)= @_; + my $install_db= extra_server_install_db($vardir); + my $dir= extra_server_dir($vardir, $num); + my $datadir= extra_server_datadir($vardir, $num); + + die "install.db not found at $install_db\n" unless -d $install_db; + + mkpath($dir) unless -d $dir; + mkpath("$vardir/log") unless -d "$vardir/log"; + + if (-d $datadir) { + $report->("Removing existing datadir: $datadir") if $report; + rmtree($datadir); + } + + $report->("Copying $install_db to $datadir...") if $report; + # The same copy mariadb-test-run.pl makes for every other mysqld. copytree() + # makes the copies writable, which install.db itself is not. + copytree($install_db, $datadir); + die "Failed to copy $install_db to $datadir\n" unless -d $datadir; + + return $datadir; +} + + +# +# The connection info of extra server $num: what start_extra_server.pl hands +# back to whoever started it, "KEY=value", one per line. Written and read here +# rather than at the four places that used to know the format. +# +sub extra_server_write_info +{ + my ($vardir, $num, %info)= @_; + my $file= extra_server_info_file($vardir, $num); + + open my $fh, '>', $file or die "Cannot write $file: $!\n"; + print $fh "$_=$info{$_}\n" for sort keys %info; + close $fh; + return $file; +} + +# Returns the info as a hash, empty if the file is not there or unreadable +sub extra_server_read_info +{ + my ($vardir, $num)= @_; + my $file= extra_server_info_file($vardir, $num); + my %info; + + return () unless -f $file; + open my $fh, '<', $file or return (); + while (<$fh>) { + chomp; + $info{$1}= $2 if /^(\w+)=(.+)/; + } + close $fh; + return %info; +} + +1; diff --git a/mysql-test/lib/start_extra_server.pl b/mysql-test/lib/start_extra_server.pl new file mode 100755 index 0000000000000..95cc2ca5439e8 --- /dev/null +++ b/mysql-test/lib/start_extra_server.pl @@ -0,0 +1,118 @@ +#!/usr/bin/env perl +# Copyright (c) 2026, MariaDB Corporation. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1335 USA + +use strict; +use warnings; +use File::Basename; +use POSIX ":sys_wait_h"; +use lib dirname(__FILE__); # mysql-test/lib, for My::ExtraServer +# Where this server keeps its files and how it is started - shared with +# mariadb-test-run.pl, see lib/My/ExtraServer.pm +use My::ExtraServer; + +# Parse arguments +my $vardir = $ENV{MYSQLTEST_VARDIR} or die "MYSQLTEST_VARDIR not set\n"; +my $server_num = shift @ARGV or die "Usage: $0 [port] [socket]\n"; +my $custom_port = shift @ARGV; +my $custom_socket = shift @ARGV; + +my $port = $custom_port || + extra_server_port($ENV{MASTER_MYPORT}, $server_num); +my $socket = $custom_socket || + extra_server_default_socket($vardir, $server_num); + +# Create data directory +my $datadir = extra_server_prepare_datadir($vardir, $server_num, + sub { print "$_[0]\n" }); + +# Start mysqld +my $mysqld = $ENV{MYSQLD} or die "MYSQLD environment variable not set\n"; +die "mysqld binary not found at $mysqld\n" unless -x $mysqld; + +my $pid_file = extra_server_pid_file($vardir, $server_num); +my $log_file = extra_server_err_log($vardir, $server_num); +my $general_log_file = extra_server_general_log($vardir, $server_num); + +my @mysqld_args = extra_server_mysqld_args($mysqld, $vardir, $server_num, + $port, $socket); + +print "Starting mysqld on port $port with socket $socket...\n"; +print "Command: " . join(" ", @mysqld_args) . "\n"; + +# Fork and start server +my $pid = fork(); +die "Fork failed: $!\n" unless defined $pid; + +if ($pid == 0) { + # Child process - start server + # Redirect stdout/stderr to log file + open STDOUT, '>>', $log_file or die "Cannot redirect STDOUT: $!\n"; + open STDERR, '>>', $log_file or die "Cannot redirect STDERR: $!\n"; + exec(@mysqld_args) or die "Failed to exec mysqld: $!\n"; +} + +# Parent - wait for server to be ready +print "Server process started with PID $pid\n"; +print "Waiting for server to be ready...\n"; + +# Wait for socket file to appear (up to 30 seconds). The socket is looked for +# once more after the last sleep, so that one appearing in the final second is +# not reported as a timeout. +my $max_wait = 30; +my $ready = 0; +for my $waited (0 .. $max_wait) { + if (-S $socket) { + print "Socket file created: $socket\n"; + $ready = 1; + last; + } + last if $waited == $max_wait; + sleep 1; + + # Check if process is still alive + if (waitpid($pid, WNOHANG) == $pid) { + die "Server process died during startup. Check $log_file for errors.\n"; + } +} + +unless ($ready) { + kill 'TERM', $pid; + die "Timeout waiting for server to start. Check $log_file for errors.\n"; +} + +# Additional wait for server to be fully ready +sleep 2; + +# Hand the connection info back to whoever started us +my $info_file = extra_server_write_info($vardir, $server_num, + HOST => "127.0.0.1", + PORT => $port, + SOCKET => $socket, + DATADIR => $datadir, + PID => $pid, + PID_FILE => $pid_file, + LOG_FILE => $log_file, + GENERAL_LOG_FILE => $general_log_file); + +print "Extra server $server_num started successfully\n"; +print "Connection info written to $info_file\n"; +print " Host: 127.0.0.1\n"; +print " Port: $port\n"; +print " Socket: $socket\n"; +print " Datadir: $datadir\n"; +print " General log: $general_log_file\n"; + +exit 0; diff --git a/mysql-test/main/extra_server_example.result b/mysql-test/main/extra_server_example.result new file mode 100644 index 0000000000000..e6a616e6d9960 --- /dev/null +++ b/mysql-test/main/extra_server_example.result @@ -0,0 +1,20 @@ +include/start_extra_server.inc [server 1] +connect extra1, 127.0.0.1, root, , test, $EXTRA_SERVER_PORT; +SELECT "Connected to extra server" AS status; +status +Connected to extra server +include/assert.inc [the connection goes to the extra server, not the test one] +CREATE TABLE t1 (id INT); +INSERT INTO t1 VALUES (1), (2), (3); +SELECT * FROM t1; +id +1 +2 +3 +connection default; +SELECT "On default server" AS status; +status +On default server +disconnect extra1; +include/stop_extra_server.inc [server 1] +Extra server 1 stopped diff --git a/mysql-test/main/extra_server_example.test.DISABLED b/mysql-test/main/extra_server_example.test.DISABLED new file mode 100644 index 0000000000000..f13503f34805d --- /dev/null +++ b/mysql-test/main/extra_server_example.test.DISABLED @@ -0,0 +1,27 @@ +# Test starting an extra server instance +--source include/not_embedded.inc + +# Start extra server +--let $extra_server_num= 1 +--source include/start_extra_server.inc + +# Connect to it. Proves that start_extra_server.inc really handed the port +# back: an empty $EXTRA_SERVER_PORT would connect to the test server instead. +--connect (extra1, 127.0.0.1, root, , test, $EXTRA_SERVER_PORT) +SELECT "Connected to extra server" AS status; +--let $extra_port_seen= `SELECT @@port` +--let $assert_text= the connection goes to the extra server, not the test one +--let $assert_cond= $extra_port_seen = $EXTRA_SERVER_PORT +--source include/assert.inc +CREATE TABLE t1 (id INT); +INSERT INTO t1 VALUES (1), (2), (3); +SELECT * FROM t1; + +# Back to default +--connection default +SELECT "On default server" AS status; + +# Stop extra server +--disconnect extra1 +--let $extra_server_num= 1 +--source include/stop_extra_server.inc diff --git a/mysql-test/main/replay_server_cleanup.result b/mysql-test/main/replay_server_cleanup.result new file mode 100644 index 0000000000000..20fb5708751e0 --- /dev/null +++ b/mysql-test/main/replay_server_cleanup.result @@ -0,0 +1,76 @@ +CREATE DATABASE replay_db1; +CREATE TABLE replay_db1.t1 (a INT, b INT, KEY(a)); +INSERT INTO replay_db1.t1 VALUES (1,1), (2,2), (3,3); +CREATE VIEW replay_db1.v1 AS SELECT * FROM replay_db1.t1; +CREATE TABLE t2 (c INT); +INSERT INTO t2 VALUES (1),(2); +ANALYZE TABLE replay_db1.t1, t2; +Table Op Msg_type Msg_text +replay_db1.t1 analyze status Engine-independent statistics collected +replay_db1.t1 analyze status OK +test.t2 analyze status Engine-independent statistics collected +test.t2 analyze status OK +EXPLAIN FORMAT=JSON SELECT * FROM replay_db1.v1 WHERE a = 1; +EXPLAIN +{ + "query_block": { + "select_id": 1, + "cost": "COST_REPLACED", + "nested_loop": [ + { + "table": { + "table_name": "t1", + "access_type": "ref", + "possible_keys": ["a"], + "key": "a", + "key_length": "5", + "used_key_parts": ["a"], + "ref": ["const"], + "loops": 1, + "rows": 1, + "cost": "COST_REPLACED", + "filtered": 100 + } + } + ] + } +} +EXPLAIN FORMAT=JSON SELECT * FROM replay_db1.t1, t2 WHERE a = c; +EXPLAIN +{ + "query_block": { + "select_id": 1, + "cost": "COST_REPLACED", + "nested_loop": [ + { + "table": { + "table_name": "t2", + "access_type": "ALL", + "loops": 1, + "rows": 2, + "cost": "COST_REPLACED", + "filtered": 100, + "attached_condition": "t2.c is not null" + } + }, + { + "table": { + "table_name": "t1", + "access_type": "ref", + "possible_keys": ["a"], + "key": "a", + "key_length": "5", + "used_key_parts": ["a"], + "ref": ["test.t2.c"], + "loops": 2, + "rows": 1, + "cost": "COST_REPLACED", + "filtered": 100 + } + } + ] + } +} +DROP VIEW replay_db1.v1; +DROP TABLE replay_db1.t1, t2; +DROP DATABASE replay_db1; diff --git a/mysql-test/main/replay_server_cleanup.test b/mysql-test/main/replay_server_cleanup.test new file mode 100644 index 0000000000000..d326bad4161a4 --- /dev/null +++ b/mysql-test/main/replay_server_cleanup.test @@ -0,0 +1,62 @@ +# Check that mysqltest restores the replay server to its baseline state after +# every replay run: the databases, tables and views that a replay script +# created must be gone once the EXPLAIN has been replayed. +# +# The test is silent when everything is in order, and it is silent when it is +# run without --replay-server (no replay server, nothing to check). That way +# the same .result serves both cases; any leftover object shows up as a +# result diff. +--source include/not_embedded.inc + +CREATE DATABASE replay_db1; +CREATE TABLE replay_db1.t1 (a INT, b INT, KEY(a)); +INSERT INTO replay_db1.t1 VALUES (1,1), (2,2), (3,3); +CREATE VIEW replay_db1.v1 AS SELECT * FROM replay_db1.t1; +CREATE TABLE t2 (c INT); +INSERT INTO t2 VALUES (1),(2); +ANALYZE TABLE replay_db1.t1, t2; + +# Note: the EXPLAINs must not be wrapped in --disable_result_log, that turns +# off the replay of the query altogether. +# +# A table and a view in a database of their own: the whole database is created +# on the replay server and has to be dropped again. +--source include/explain-no-costs.inc +EXPLAIN FORMAT=JSON SELECT * FROM replay_db1.v1 WHERE a = 1; +# A table in the default database: `test` belongs to the baseline, so here the +# table alone has to be dropped. +--source include/explain-no-costs.inc +EXPLAIN FORMAT=JSON SELECT * FROM replay_db1.t1, t2 WHERE a = c; + +--perl +my $sock= $ENV{REPLAY_SERVER_SOCKET}; +exit(0) unless $sock; + +# Report anything the replay scripts left behind. mysql.*_stats rows are +# included because a stale row can change the plan of a later replay; DROP +# TABLE/DATABASE is expected to have removed them along with the tables. +# The statements go in through stdin so that the quotes in them do not have +# to survive the shell. +my $sql= <<'END_OF_SQL'; +SELECT concat('leftover database: ', SCHEMA_NAME) + FROM information_schema.SCHEMATA WHERE SCHEMA_NAME LIKE 'replay\_%'; +SELECT concat('leftover table/view: ', TABLE_SCHEMA, '.', TABLE_NAME) + FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'test'; +SELECT concat('leftover stats: ', db_name, '.', table_name) + FROM mysql.table_stats; +SELECT concat('leftover stats: ', db_name, '.', table_name, '.', column_name) + FROM mysql.column_stats; +SELECT concat('leftover stats: ', db_name, '.', table_name, '.', index_name) + FROM mysql.index_stats; +END_OF_SQL + +open(my $client, "|-", + "$ENV{MYSQL} --socket=$sock --batch --skip-column-names test") + or die "Could not run $ENV{MYSQL}: $!"; +print $client $sql; +close($client); +EOF + +DROP VIEW replay_db1.v1; +DROP TABLE replay_db1.t1, t2; +DROP DATABASE replay_db1; diff --git a/mysql-test/main/replay_server_test.result b/mysql-test/main/replay_server_test.result new file mode 100644 index 0000000000000..e029281ab0e26 --- /dev/null +++ b/mysql-test/main/replay_server_test.result @@ -0,0 +1,88 @@ +CREATE TABLE t1 (a INT, b INT, KEY(a)); +INSERT INTO t1 VALUES (1,1), (2,2), (3,3); +analyze table t1; +Table Op Msg_type Msg_text +test.t1 analyze status Engine-independent statistics collected +test.t1 analyze status OK +EXPLAIN FORMAT=JSON SELECT * FROM t1 WHERE a = 1; +EXPLAIN +{ + "query_block": { + "select_id": 1, + "cost": "COST_REPLACED", + "nested_loop": [ + { + "table": { + "table_name": "t1", + "access_type": "ref", + "possible_keys": ["a"], + "key": "a", + "key_length": "5", + "used_key_parts": ["a"], + "ref": ["const"], + "loops": 1, + "rows": 1, + "cost": "COST_REPLACED", + "filtered": 100 + } + } + ] + } +} +EXPLAIN FORMAT=JSON +SELECT * FROM t1 WHERE a < 100; +EXPLAIN +{ + "query_block": { + "select_id": 1, + "cost": "COST_REPLACED", + "nested_loop": [ + { + "table": { + "table_name": "t1", + "access_type": "range", + "possible_keys": ["a"], + "key": "a", + "key_length": "5", + "used_key_parts": ["a"], + "loops": 1, + "rows": 3, + "cost": "COST_REPLACED", + "filtered": 100, + "index_condition": "t1.a < 100" + } + } + ] + } +} +SELECT * FROM t1 WHERE a < 100; +a b +1 1 +2 2 +3 3 +explain +SELECT * FROM t1 WHERE a < 22; +id select_type table type possible_keys key key_len ref rows Extra +1 SIMPLE t1 range a a 5 NULL 3 Using index condition +explain extended +SELECT * FROM t1 WHERE a < 22; +id select_type table type possible_keys key key_len ref rows filtered Extra +1 SIMPLE t1 range a a 5 NULL 3 100.00 Using index condition +Warnings: +Note 1003 select `test`.`t1`.`a` AS `a`,`test`.`t1`.`b` AS `b` from `test`.`t1` where `test`.`t1`.`a` < 22 +explain +SELECT * FROM t1 WHERE b < 22; +id select_type table type possible_keys key key_len ref rows Extra +1 SIMPLE t1 ALL NULL NULL NULL NULL 3 Using where +explain extended select LENGTH(CONCAT('aa','bbb')); +id select_type table type possible_keys key key_len ref rows filtered Extra +1 SIMPLE NULL NULL NULL NULL NULL NULL NULL NULL No tables used +Warnings: +Note 1003 select octet_length(concat('aa','bbb')) AS `LENGTH(CONCAT('aa','bbb'))` +create function add1(i int) returns int deterministic +return i+1; +explain select * from t1 where b< add1(b); +id select_type table type possible_keys key key_len ref rows Extra +1 SIMPLE t1 ALL NULL NULL NULL NULL 3 Using where +drop function add1; +DROP TABLE t1; diff --git a/mysql-test/main/replay_server_test.test b/mysql-test/main/replay_server_test.test new file mode 100644 index 0000000000000..19c2995a36659 --- /dev/null +++ b/mysql-test/main/replay_server_test.test @@ -0,0 +1,45 @@ +# Exercise the replay-server mode of mysqltest: under mtr --replay-server the +# EXPLAINs below are replayed on the replay server and its output is what ends +# up in the result. The same .result serves a run without --replay-server, +# where the test server answers them itself. +--source include/not_embedded.inc + +# Test ReplayTest mode with EXPLAIN FORMAT=JSON +CREATE TABLE t1 (a INT, b INT, KEY(a)); +INSERT INTO t1 VALUES (1,1), (2,2), (3,3); +analyze table t1; + +# This should trigger ReplayTest mode if REPLAY_SERVER_SOCKET is set +--source include/explain-no-costs.inc +EXPLAIN FORMAT=JSON SELECT * FROM t1 WHERE a = 1; + +--source include/explain-no-costs.inc +EXPLAIN FORMAT=JSON +SELECT * FROM t1 WHERE a < 100; + +SELECT * FROM t1 WHERE a < 100; + +explain +SELECT * FROM t1 WHERE a < 22; + +explain extended +SELECT * FROM t1 WHERE a < 22; + +explain +SELECT * FROM t1 WHERE b < 22; + +explain extended select LENGTH(CONCAT('aa','bbb')); + +# +# Check if disable replay works +# +create function add1(i int) returns int deterministic + return i+1; + +# The following would give this error: +# ReplayTest: Query error: FUNCTION test.add1 does not exist +--disable_replay next_query Don't support SPs. +explain select * from t1 where b< add1(b); + +drop function add1; +DROP TABLE t1; diff --git a/mysql-test/mariadb-test-run.pl b/mysql-test/mariadb-test-run.pl index b1ebc30f84438..da54b0357587c 100755 --- a/mysql-test/mariadb-test-run.pl +++ b/mysql-test/mariadb-test-run.pl @@ -94,6 +94,7 @@ BEGIN use My::SysInfo; use My::CoreDump; use My::Debugger; +use My::ExtraServer; use mtr_cases; use mtr_report; use mtr_match; @@ -150,6 +151,14 @@ BEGIN our @global_suppressions; +# Forward declarations for variables referenced in END block +our $opt_replay_server; +our $opt_replay_server_manual; +our $opt_replay_server_trace; +our $opt_replay_server_no_cleanup; +our $replay_server_parent_pid; # PID of process that started the replay server +our $is_worker; # TRUE in the forked test workers + END { if ( defined $opt_tmpdir_pid and $opt_tmpdir_pid == $$ ) { @@ -164,6 +173,15 @@ END mtr_warning("tmpdir $opt_tmpdir should be removed after the server has finished"); } } + + # Ensure replay server is stopped on any exit path (success or failure). + # Only run in the parent process that started it; safe to call even if + # already stopped (stop_replay_server clears REPLAY_SERVER_PID). + if (defined $replay_server_parent_pid and $replay_server_parent_pid == $$ + and _replay_server_enabled()) + { + eval { stop_replay_server(); }; + } } sub env_or_val($$) { defined $ENV{$_[0]} ? $ENV{$_[0]} : $_[1] } @@ -281,6 +299,9 @@ END our $opt_gprof; our %gprof_dirs; +# $opt_replay_server and $opt_replay_server_manual are declared earlier in the +# file for the END block. See the "Forward declarations" comment near the top. + my $config; # The currently running config my $current_config_name; # The currently running config file template @@ -425,6 +446,24 @@ sub main { { mysql_install_db(default_mysqld(), "$opt_vardir/install.db"); make_readonly("$opt_vardir/install.db"); + + # Start replay server if --replay-server option is specified. + # Refuse if --parallel > 1 was explicitly requested; the replay server is + # a single shared instance and cannot serve multiple concurrent workers. + # (The "auto" case is resolved later and re-checked below.) + if (_replay_server_enabled() && + $opt_parallel ne "auto" && $opt_parallel > 1) + { + _replay_parallel_error(); + } + if ( $opt_replay_server ) + { + start_replay_server(); + } + elsif ( $opt_replay_server_manual ) + { + start_replay_server_manual(); + } } if ($opt_dry_run) { @@ -469,6 +508,23 @@ sub main { $opt_parallel= 1; } + # The "auto" case above may have resolved to more than one worker + if ($opt_parallel > 1 && _replay_server_enabled()) { + _replay_parallel_error(); + } + + # Propagate --replay-server-trace to mysqltest via environment variable. + if ($opt_replay_server_trace) { + $ENV{REPLAY_SERVER_TRACE} = 1; + } + + # Propagate --replay-server-no-cleanup to mysqltest. By default mysqltest + # drops the databases/tables/views that a replay script created on the + # replay server; this leaves them there for inspection. + if ($opt_replay_server_no_cleanup) { + $ENV{REPLAY_SERVER_NO_CLEANUP} = 1; + } + # Create server socket on any free port my $server = new IO::Socket::INET ( @@ -596,6 +652,9 @@ sub main { remove_vardir_subs() if $opt_clean_vardir; + # Stop replay server if it was started + stop_replay_server() if _replay_server_enabled(); + exit(0); } @@ -988,6 +1047,8 @@ package main; sub run_worker ($) { my ($server_port, $thread_num)= @_; + $is_worker= 1; + $SIG{INT}= sub { exit(1); }; $SIG{HUP}= sub { exit(1); }; @@ -1012,7 +1073,19 @@ ($) # -------------------------------------------------------------------------- # Set different ports per thread # -------------------------------------------------------------------------- - set_build_thread_ports($thread_num); + # When --replay-server / --replay-server-manual is active, the parent has + # already allocated the build-thread / baseport (so it could start the + # replay server inside that port group), and the replay server is already + # listening inside the group. Re-running set_build_thread_ports() here + # would call check_ports_free() and fail because of that listener. Since + # parallel must be 1 in this mode, the parent's allocation is exactly what + # this single worker needs - just inherit it. + if (_replay_server_enabled() && defined $baseport) { + mtr_verbose("Worker inheriting baseport=$baseport from parent " . + "(--replay-server active)"); + } else { + set_build_thread_ports($thread_num); + } # -------------------------------------------------------------------------- # Turn off verbosity in workers, unless explicitly specified @@ -1283,6 +1356,10 @@ sub command_line_setup { 'skip-test-list=s' => \@opt_skip_test_list, 'xml-report=s' => \$opt_xml_report, 'open-files-limit=i', => \$opt_open_files_limit, + 'replay-server' => \$opt_replay_server, + 'replay-server-manual' => \$opt_replay_server_manual, + 'replay-server-trace' => \$opt_replay_server_trace, + 'replay-server-no-cleanup' => \$opt_replay_server_no_cleanup, My::Debugger::options(), My::CoreDump::options(), @@ -3051,6 +3128,453 @@ sub initialize_servers { } +# +# The replay server: how long to wait for it, how to reach it, where its +# files are. +# +# The file layout and the mysqld command line are shared with +# lib/start_extra_server.pl through My::ExtraServer. The environment variables +# are the ones client/mysqltest.cc reads (REPLAY_ENV_* over there); each is +# named here once, in the accessor below it. +# +# Constants, not variables: main() runs before file-scope assignments do. +use constant { + REPLAY_PING_TIMEOUT => 5, # seconds one liveness ping may take + REPLAY_STALE_KILL_WAIT => 5, # ... to wait for a stale server to go + REPLAY_STOP_TERM_WAIT => 10, # ... for SIGTERM to stop the server + REPLAY_STOP_KILL_WAIT => 3, # ... for SIGKILL to do the same + REPLAY_MANUAL_START_TIMEOUT => 300, # ... for a manually started server + REPLAY_MANUAL_REPORT_EVERY => 10, # ... between "still waiting" reports + REPLAY_MANUAL_POLL_EVERY => 2, # ... between pings while waiting + REPLAY_SETTLE_WAIT => 2, # ... after the socket shows up, before + # the server is talked to +}; + +# True when this run has a replay server, started by us or by the user +sub _replay_server_enabled { + return ($opt_replay_server || $opt_replay_server_manual); +} + +# The socket mysqltest connects to the replay server on +sub _replay_socket { return $ENV{REPLAY_SERVER_SOCKET}; } +sub _set_replay_socket { $ENV{REPLAY_SERVER_SOCKET}= $_[0]; } + +# Where the replay server keeps its files - it is extra server +# EXTRA_SERVER_NUM, see lib/My/ExtraServer.pm +sub _replay_socket_path { extra_server_socket($opt_vardir, EXTRA_SERVER_NUM) } +sub _replay_server_pid_file + { extra_server_pid_file($opt_vardir, EXTRA_SERVER_NUM) } +sub _replay_info_file { extra_server_info_file($opt_vardir, + EXTRA_SERVER_NUM) } + +# Refuse the run: a single replay server cannot serve several workers +sub _replay_parallel_error { + mtr_error("--replay-server / --replay-server-manual cannot be used " . + "together with --parallel > 1 (parallel=$opt_parallel). " . + "The replay server is a single shared instance and cannot " . + "serve multiple concurrent workers. " . + "Re-run with --parallel=1."); +} + + +sub _install_replay_server_signal_handlers { + # Ensure the END block (which stops the replay server) runs on termination + # signals: Perl END blocks don't run on an uncaught signal, a handler that + # calls exit() lets them. INT and HUP already have such a handler - they + # end up in mtr_error(), which exits - so only TERM is added here, and the + # reports those two print are kept. + $SIG{TERM} = sub { mtr_error("Got TERM signal"); }; +} + + +# +# Shared PID file so worker-process restarts of the replay server are visible +# to the parent's stop logic (END block). Not to be confused with the server's +# own --pid-file, _replay_server_pid_file(). +# +sub _replay_current_pid_file { + return "$opt_vardir/tmp/replay_server.current_pid"; +} + +sub _write_replay_pid_file { + my ($pid) = @_; + return unless defined $pid; + mtr_tonewfile(_replay_current_pid_file(), "$pid\n"); +} + +sub _read_replay_pid_file { + my $path = _replay_current_pid_file(); + return undef unless -f $path; + my $pid = mtr_fromfile($path); + return ($pid =~ /^\d+$/) ? $pid : undef; +} + +# +# The pid out of the replay server's own --pid-file, undef if it has not +# written one (yet). Not to be confused with _read_replay_pid_file(), which +# reads the file mtr keeps for itself. +# +sub _replay_read_own_pid_file { + my $path = _replay_server_pid_file(); + return undef unless -f $path; + my $pid = mtr_fromfile($path); + return ($pid =~ /^\d+$/) ? $pid : undef; +} + + +# +# The pid of the replay server. It is kept in the environment, for the workers, +# and in the shared file, for the parent's stop logic; the file may be the +# fresher of the two, so it wins. +# +sub _replay_server_pid { + return _read_replay_pid_file() // $ENV{REPLAY_SERVER_PID}; +} + +sub _set_replay_server_pid { + my ($pid) = @_; + $ENV{REPLAY_SERVER_PID} = $pid; + _write_replay_pid_file($pid); +} + +# Forget the pid, marking the server stopped for the calls that follow. Both +# copies go, or _replay_server_pid() would keep returning the stale one. +sub _clear_replay_server_pid { + delete $ENV{REPLAY_SERVER_PID}; + unlink _replay_current_pid_file(); +} + +# +# Stop process $pid: SIGTERM, wait up to $term_wait seconds for it to go, then +# SIGKILL and wait up to $kill_wait more. A no-op if it is already gone. +# +# The replay server is not a child of this process (start_extra_server.pl is +# an intermediate), so waitpid() cannot poll it - "kill 0" (signal 0, an +# existence check) is used instead. +# +sub _replay_stop_pid { + my ($pid, $term_wait, $kill_wait) = @_; + + return unless kill(0, $pid); + + kill 'TERM', $pid; + for my $waited (1 .. $term_wait) { + last unless kill(0, $pid); + sleep 1; + } + + return unless kill(0, $pid); + + kill 'KILL', $pid; + for my $waited (1 .. $kill_wait) { + last unless kill(0, $pid); + sleep 1; + } +} + + +# +# Ping the replay server with SELECT '' AS next_testcase, enforcing +# a REPLAY_PING_TIMEOUT second timeout. Returns 1 on success, 0 on +# failure/timeout. +# +sub _ping_replay_server { + my ($test_name) = @_; + my $sock = _replay_socket(); + return 0 unless $sock && -S $sock; + + # $exe_mysql was resolved once, by environment_setup() + return 0 unless $exe_mysql && -x $exe_mysql; + + # Escape single quotes in test name for SQL. + my $escaped = $test_name; + $escaped =~ s/'/''/g; + my $sql = "SELECT '$escaped' AS next_testcase"; + + my $pid = fork(); + if (!defined $pid) { + mtr_warning("fork() failed in _ping_replay_server: $!"); + return 0; + } + if ($pid == 0) { + # Child: run the client, redirect output to /dev/null, exec. + open(STDIN, '<', '/dev/null'); + open(STDOUT, '>', '/dev/null'); + open(STDERR, '>', '/dev/null'); + exec($exe_mysql, + "--no-defaults", + "--protocol=socket", + "--socket=$sock", + "--user=root", + "--connect-timeout=3", + "-N", "-B", + "-e", $sql) + or POSIX::_exit(127); + } + + # Parent: wait for the ping to come back. + my $status; + my $timed_out = 0; + eval { + local $SIG{ALRM} = sub { die "timeout\n" }; + alarm(REPLAY_PING_TIMEOUT); + waitpid($pid, 0); + $status = $?; + alarm(0); + }; + if ($@) { + # Timeout. + alarm(0); + $timed_out = 1; + kill 'KILL', $pid; + waitpid($pid, 0); + } + return 0 if $timed_out; + return ($status == 0) ? 1 : 0; +} + +# +# Before each test, verify that the replay server is alive and responsive. +# If not, kill the stale process (if any) and restart the server (for +# --replay-server) or wait for the user to restart it (--replay-server-manual). +# +sub check_replay_server { + my ($test_name) = @_; + return unless _replay_server_enabled(); + return unless _replay_socket(); + + return if _ping_replay_server($test_name); + + print STDERR "mysql-test-run: *** WARNING: Replay server unresponsive " . + "before test '$test_name'\n"; + + # Kill any stale process from the old PID. + my $old_pid = _replay_server_pid(); + if (defined $old_pid && $old_pid =~ /^\d+$/ && kill(0, $old_pid)) { + print STDERR "mysql-test-run: killing stale replay server (pid $old_pid)\n"; + _replay_stop_pid($old_pid, REPLAY_STALE_KILL_WAIT, 1); + } + + # Remove stale socket / pid / info so restart can succeed. + for my $stale (_replay_socket_path(), _replay_server_pid_file(), + _replay_info_file()) { + unlink $stale if -e $stale; + } + + if ($opt_replay_server) { + print STDERR "mysql-test-run: restarting replay server...\n"; + start_replay_server(); + print STDERR "mysql-test-run: replay server restarted (pid " . + (_replay_server_pid() // "?") . ")\n"; + } else { + # --replay-server-manual: can't auto-restart. Wait for the user. + print STDERR "mysql-test-run: --replay-server-manual is set; " . + "waiting for you to restart the replay server on socket " . + _replay_socket() . " ...\n"; + while (!_ping_replay_server($test_name)) { + sleep REPLAY_MANUAL_POLL_EVERY; + } + # Try to refresh the PID from the pid file written by the user's server. + my $new_pid = _replay_read_own_pid_file(); + _set_replay_server_pid($new_pid) if defined $new_pid; + print STDERR "mysql-test-run: replay server is responsive again, " . + "continuing with test '$test_name'\n"; + } +} + + +# +# Common ground of the two start paths: take ownership of the teardown, make +# sure there is a port group to put the replay server in, and give +# lib/start_extra_server.pl (or the user, in manual mode) the environment it +# reads. +# +sub _prepare_replay_server_start { + # + # Only the process that owns the run tears the replay server down again. A + # worker restarting the server through check_replay_server() must not claim + # that: its END block would stop the server and remove the shared pid file + # while the parent still has tests to run, and the parent would afterwards + # signal a pid that may since have been reused. The worker does publish the + # new pid in the shared file, which is where the parent reads it from. + # + $replay_server_parent_pid = $$ unless $is_worker; + _install_replay_server_signal_handlers(); + + # Allocate baseport from MTR_BUILD_THREAD (same as primary mysqld), so the + # replay server uses a port inside the reserved group instead of the fixed + # 10000 fallback. Replay server requires --parallel=1, so reusing thread 1 + # is safe; lock the resolved build-thread so the forked worker reuses the + # same number (otherwise an "auto" worker would acquire a different unique + # id and primary mysqld would land in a different port group than the + # replay server). + if (!defined $baseport) { + set_build_thread_ports(1); + $opt_build_thread = $build_thread; + } + + # extra_server_port() places the server at the top of this run's port group + $ENV{MTR_PORT_GROUP_SIZE} = $opt_port_group_size; + + # environment_setup() has already run, so most of these are already set + $ENV{MYSQLTEST_VARDIR} = $opt_vardir unless $ENV{MYSQLTEST_VARDIR}; + $ENV{MASTER_MYPORT} = $baseport unless $ENV{MASTER_MYPORT}; + $ENV{MYSQL_TEST_DIR} = $glob_mysql_test_dir unless $ENV{MYSQL_TEST_DIR}; + $ENV{MYSQLD} = find_mysqld($basedir) unless $ENV{MYSQLD}; +} + + +sub start_replay_server { + mtr_report("Starting replay server..."); + _prepare_replay_server_start(); + + my $script = "$glob_mysql_test_dir/lib/start_extra_server.pl"; + + unless (-f $script) { + mtr_error("Replay server script not found: $script"); + } + + # Pass the socket in the server's own directory rather than letting the + # script default to one in the tmp directory, which is cleaned up while the + # tests run. Empty port: the script derives it from MASTER_MYPORT. + my $result = system($^X, $script, EXTRA_SERVER_NUM, "", + _replay_socket_path()); + + if ($result != 0) { + mtr_error("Failed to start replay server (exit code: $result)"); + } + + my %info = extra_server_read_info($opt_vardir, EXTRA_SERVER_NUM); + unless ($info{SOCKET} && $info{PID}) { + mtr_error("Replay server info file not found or incomplete: " . + _replay_info_file()); + } + + # Store for cleanup and export to environment + _set_replay_socket($info{SOCKET}); + _set_replay_server_pid($info{PID}); + + mtr_report("Replay server started on socket: $info{SOCKET}"); +} + + +sub start_replay_server_manual { + mtr_report("Starting replay server in manual mode..."); + _prepare_replay_server_start(); + + my $mysqld = $ENV{MYSQLD}; + die "mysqld binary not found at $mysqld\n" unless -x $mysqld; + + # The same paths, port and command line start_extra_server.pl would use, so + # that the server the user starts by hand is the one mtr expects. + my $port = extra_server_port($ENV{MASTER_MYPORT}, EXTRA_SERVER_NUM); + my $socket = _replay_socket_path(); + my $pid_file = _replay_server_pid_file(); + + extra_server_prepare_datadir($opt_vardir, EXTRA_SERVER_NUM, + sub { mtr_report($_[0]) }); + + # for_debugger: manual mode exists so that the server can be run under a + # debugger, which is what mysqld's --gdb is for + my @mysqld_args = extra_server_mysqld_args($mysqld, $opt_vardir, + EXTRA_SERVER_NUM, + $port, $socket, 1); + + # Write a gdb init file so the user can run: + # gdb -x var/tmp/gdbinit-replay + # The file contains a single "set args ..." line with all mysqld arguments + # (excluding the mysqld binary itself, which gdb takes separately). + my $gdbinit_file = "$opt_vardir/tmp/gdbinit-replay"; + mtr_tofile($gdbinit_file, + "set args " . join(" ", @mysqld_args[1 .. $#mysqld_args]) . "\n"); + + # Print command line for user + mtr_report("=" x 70); + mtr_report("REPLAY SERVER MANUAL MODE"); + mtr_report("=" x 70); + mtr_report(""); + mtr_report("Please start the replay server with the following command:"); + mtr_report(""); + mtr_report(join(" \\\n ", @mysqld_args)); + mtr_report(""); + mtr_report("Or run under gdb:"); + mtr_report("gdb --args " . join(" \\\n ", @mysqld_args)); + mtr_report(""); + mtr_report("gdb init file written to: $gdbinit_file"); + mtr_report(" gdb -x $gdbinit_file $mysqld"); + mtr_report(""); + mtr_report("Waiting for socket file to appear: $socket"); + mtr_report("(Timeout: " . REPLAY_MANUAL_START_TIMEOUT . " seconds)"); + mtr_report("=" x 70); + + # Wait for socket file to appear + my $waited = 0; + my $last_msg = 0; + + while ($waited < REPLAY_MANUAL_START_TIMEOUT) { + if (-S $socket) { + mtr_report("Socket file detected: $socket"); + last; + } + + sleep 1; + $waited++; + + if ($waited - $last_msg >= REPLAY_MANUAL_REPORT_EVERY) { + mtr_report("Still waiting for socket... ($waited seconds elapsed)"); + $last_msg = $waited; + } + } + + if ($waited >= REPLAY_MANUAL_START_TIMEOUT) { + die "Timeout waiting for replay server socket to appear: $socket\n"; + } + + # Give server a moment to be fully ready + sleep REPLAY_SETTLE_WAIT; + + # The command line above told the server to write $pid_file; give it a + # moment to appear, since the socket may show up first. + my $pid; + for (1 .. REPLAY_SETTLE_WAIT + 1) { + $pid = _replay_read_own_pid_file(); + last if $pid && kill(0, $pid); + sleep 1; + } + + if (!$pid || !kill(0, $pid)) { + die "Could not read the pid of the replay server from $pid_file. " . + "Please check if it is running.\n"; + } + + # Store for cleanup and export to environment + _set_replay_socket($socket); + _set_replay_server_pid($pid); + + mtr_report("Replay server detected with PID: $pid"); + mtr_report("Socket: $socket"); + mtr_report("Replay server is ready!"); +} + + +sub stop_replay_server { + return unless _replay_server_enabled(); + my $pid = _replay_server_pid(); + return unless $pid; + + mtr_report("Stopping replay server..."); + + _replay_stop_pid($pid, REPLAY_STOP_TERM_WAIT, REPLAY_STOP_KILL_WAIT); + + unlink _replay_info_file(); + + # Mark as stopped so subsequent calls (e.g. from END block) are no-ops + _clear_replay_server_pid(); + + mtr_report("Replay server stopped"); +} + + # # Remove all newline characters expect after semicolon # @@ -3863,6 +4387,9 @@ ($$) $ENV{'MTR_TEST_NAME'} = $tinfo->{name}; resfile_report_test($tinfo) if $opt_resfile; + # Verify the replay server is alive before running the test. + check_replay_server($tinfo->{name}); + for my $key (grep { /^MTR_COMBINATION/ } keys %ENV) { delete $ENV{$key}; @@ -6089,6 +6616,18 @@ ($) timer Show test case execution time. verbose More verbose output(use multiple times for even more) verbose-restart Write when and why servers are restarted + replay-server Start an extra server instance before running tests. + Socket path available via REPLAY_SERVER_SOCKET env var. + replay-server-manual Print replay server command line and wait for user to + start it manually. Useful for running under debugger. + MTR will wait for socket and manage server lifecycle. + replay-server-trace Enable replay-server tracing in mysqltest by exporting + REPLAY_SERVER_TRACE=1 to its environment. + replay-server-no-cleanup + Do not drop the databases/tables/views that a replay + script created on the replay server. By default they + are dropped after each replay run so that the server + is back to the state it had before the run. start Only initialize and start the servers, using the startup settings for the first specified test case Example: