1
0
mirror of https://github.com/MariaDB/server.git synced 2026-10-23 18:49:29 +03:00
Files
mariadb/sql/sql_parallel_thread.cc
Rex Johnston 65413b62a3 Parallel Query: give the worker back optimizer_replay_context too
main.parallel_query_clone aborted in safemalloc while a worker's THD was being
destroyed:

  free_memory (mysys/safemalloc.c:275)   DBUG_ASSERT(irem->marker == MAGICSTART)
  my_free
  plugin_thdvar_cleanup (sql/sql_plugin.cc:3429)
  THD::free_connection -> ~THD -> destroy_background_thd
  pwt_thread::init_and_run_thread_func

A cleared marker is a second free of something already freed.

A worker THD takes the session's variables wholesale, so that an item
evaluated in a worker sees the session's time zone and the rest, and then puts
back the members a THD allocates and frees for itself. That list has to be
plugin_thdvar_cleanup()'s list, and it had fallen one behind it: the rebase
onto current main added

  my_free(thd->variables.optimizer_replay_context);

alongside the redirect_url and default_master_connection frees. It is a
SESSION_ONLY Sys_var_charptr, so it is allocated per THD -- but it was not
being restored, so every worker carried away the manager's pointer, the first
worker to exit freed it, and the next free of the same pointer found the marker
gone.

Restored with the others, and the invariant that was only implied is now
written down next to the list: whatever plugin_thdvar_cleanup() frees, this has
to give back. The failure is a long way from either end, so the next member
added there will not announce itself.

This commit was prepared with Claude Code: it read the abort as a double free
rather than a cross-thread free, and diffed what plugin_thdvar_cleanup() frees
against what the worker restores to find the member the rebase had added.
2026-09-07 14:08:35 +12:00

587 lines
21 KiB
C++

#include "sql_parallel_thread.h"
#include "mariadb.h"
#include "mysqld_error.h"
#include "sql_class.h"
#include "sql_priv.h"
#include "unireg.h"
#include "sql_select.h"
#include "sql_parallel_workers.h"
#include "debug_sync.h"
#include "transaction.h"
extern MYSQL_THD create_background_thd();
extern void *thd_attach_thd(MYSQL_THD thd);
extern void thd_detach_thd(void *save);
extern void destroy_background_thd(MYSQL_THD thd);
/**
@brief
Save error condition into pwt_queued_event object.
@return
true an error occurred
false error or warning is provided in *event
*/
static
bool save_error_to_queued_event(THD *thd, pwt_queued_event **event, uint error,
Sql_condition::enum_warning_level level,
const char *msg)
{
DBUG_EXECUTE_IF("pwt_error_to_queue_oom",
{ *event= nullptr; return true; });
*event= (pwt_queued_event*) my_malloc(key_memory_pwt_queued_event,
sizeof(pwt_queued_event),
MYF(0));
if (!*event)
return true;
(*event)->error= (pwt_error_message*) my_malloc(key_memory_pwt_error_message,
sizeof(pwt_error_message),
MYF(0));
if (!(*event)->error)
{
my_free(*event);
*event= nullptr;
return true;
}
(*event)->error->level= level;
if (level == Sql_condition::enum_warning_level::WARN_LEVEL_ERROR)
(*event)->error->worker_errno= thd->killed_errno();
else
(*event)->error->worker_errno= 0;
(*event)->error->code= error;
(*event)->error->message= (char *) my_malloc(key_memory_pwt_error_message,
strlen(msg)+1,
MYF(0));
if (!(*event)->error->message)
{
my_free((*event)->error);
my_free(*event);
*event= nullptr;
return true;
}
strmake((*event)->error->message, msg, strlen(msg));
return false;
}
/*
Capture errors and warnings and redirect them to worker->manager.
*/
class PWT_error_handler : public Internal_error_handler
{
pwt_thread_manager *manager;
pwt_thread *worker;
public:
PWT_error_handler(pwt_thread_manager *manager_arg, pwt_thread *worker_arg) :
manager(manager_arg), worker(worker_arg) {}
bool handle_condition(THD *thd,
uint sql_errno,
const char* sql_state,
Sql_condition::enum_warning_level *level,
const char* msg,
Sql_condition ** cond_hdl) override
{
/*
A genuine error (not a warning) raised while the worker runs the query
-- e.g. a WHERE/projection/join evaluation error -- must abort the whole
query. Trip fatal_error so the worker stops producing and the manager
aborts instead of sending a truncated result. The error text is still
relayed to the manager below and surfaced from finalize.
Exclude a killed worker's own ER_QUERY_INTERRUPTED: a KILL is reported
separately via kill_signal (which also carries the kill type, so a
KILL vs KILL QUERY of a worker maps to dropping the manager connection
vs just its query). Tripping fatal_error here would make the manager
take the generic-error path and lose that distinction.
*/
if (*level == Sql_condition::WARN_LEVEL_ERROR && !thd->killed)
{
//TODO: and we don't save which error we've got?
// Do we save it right below? If yes, why don't we
// enqueue it first?
// An error appears to have multiple elements, so we contain it, then
// queue it.
worker->on_fatal_error();
}
pwt_queued_event *event;
if (save_error_to_queued_event(thd, &event, sql_errno, *level, msg))
{
/*
Couldn't allocate the queued event. The worker THD's diagnostics
area is discarded when the worker exits, so flag the manager so it
can surface a single ER_OUTOFMEMORY warning to the user instead of
letting this condition vanish.
*/
manager->notify_message_dropped();
return true;
}
manager->record_event(event);
return true; // no further processing in worker thread
}
};
void pwt_thread_manager::notify_message_dropped()
{
mysql_mutex_lock(&LOCK_pwt_manager);
messages_dropped= true;
mysql_mutex_unlock(&LOCK_pwt_manager);
}
#ifdef HAVE_PSI_INTERFACE
static PSI_thread_key key_thread_pwt;
static PSI_thread_info all_pwt_threads[]=
{
{ &key_thread_pwt, WORKER_NAME, PSI_FLAG_GLOBAL},
};
static PSI_mutex_key key_mutex_pwt_LOCK_worker,
key_mutex_pwt_LOCK_manager;
static PSI_mutex_info all_pwt_mutexes[]=
{
{ &key_mutex_pwt_LOCK_manager, "pwt_manager::LOCK_pwt_manager", 0},
{ &key_mutex_pwt_LOCK_worker, "pwt_worker::LOCK_worker", 0},
};
void pwt_threads_init_psi_keys(void)
{
const char *category= "sql";
PSI_server->register_thread(category, all_pwt_threads,
array_elements(all_pwt_threads));
mysql_mutex_register(category, all_pwt_mutexes,
array_elements(all_pwt_mutexes));
}
#endif
void *pwt_thread_func(void *arg)
{
DBUG_ENTER("parallel_worker_thread_func");
pwt_thread *thread= (pwt_thread*) arg;
thread->init_and_run_thread_func();
return nullptr;
}
bool pwt_thread::create_thread()
{
if (mysql_thread_create(key_thread_pwt, &pthread, /*pthread_attr_t*/ nullptr,
pwt_thread_func, this))
return 1;
thread_started= true;
return 0;
}
void pwt_thread::init_and_run_thread_func()
{
PWT_error_handler error_handler(manager_base, this);
/*
Set current_thd and thread local storage (my_thread_var) for our new THD
to ensure they have their own local objects/errors/warnings etc
*/
void *save= thd_attach_thd(thd);
/*
create_background_thd()'s THD(0) never ran the connection-setup path that
allocates debug_sync_control, so DEBUG_SYNC actions on this worker (e.g. the
pwt_worker_pause_before_signal sync point used by the tests) would assert on
a NULL control block. Initialise it here, on the worker thread, so the
MY_THREAD_SPECIFIC allocation is charged to this THD and freed symmetrically
by ~THD (debug_sync_end_thread) when destroy_background_thd() runs below.
No-op in non-DEBUG_SYNC builds and when debug_sync is inactive.
*/
#ifdef ENABLED_DEBUG_SYNC
if (!thd->debug_sync_control)
debug_sync_init_thread(thd);
#endif
my_thread_set_name(thd->connection_name.str);
THD_STAGE_INFO(thd, stage_sending_data);
thd->push_internal_handler(&error_handler);
DBUG_EXECUTE_IF("pwt_error_to_queue_oom",
{
push_warning(current_thd, Sql_condition::WARN_LEVEL_WARN, ER_UNKNOWN_ERROR,
"This is an example warning to show we can push a "
"warning from a worker thread to its manager ");
});
#ifdef ENABLED_DEBUG_SYNC
/*
we can't sync on the managers or our THD, spin the whole thing about
and use the global signal pool, NO_CLEAR_EVENT is needed because we have
multiple workers and the wrong one will likely consume the signal.
*/
DBUG_EXECUTE_IF("pwt_worker_pause_before_signal",
DBUG_ASSERT(!debug_sync_set_action(thd, STRING_WITH_LEN(
"now SIGNAL pwt_worker_paused WAIT_FOR pwt_worker_continue NO_CLEAR_EVENT"
))););
// TODO: what is this for? Test coverage for the above?
// yes
mysql_mutex_lock(&thd->LOCK_thd_kill);
if (thd->killed)
{
my_error(ER_QUERY_INTERRUPTED, MYF(0));
}
mysql_mutex_unlock(&thd->LOCK_thd_kill);
#endif
thread_func();
// manager needs to see this as atomic
mysql_mutex_lock(&LOCK_worker);
/*
LOCK_thd_kill is the canonical guard for thd->killed; a user-issued
KILL on this worker's thread_id goes through THD::awake() which holds
LOCK_thd_kill but not LOCK_worker, so we must nest both to get a
race-free snapshot for the manager.
*/
mysql_mutex_lock(&thd->LOCK_thd_kill);
mysql_mutex_unlock(&thd->LOCK_thd_kill);
thd->pop_internal_handler(); // maybe not needed
/*
Null it while we still hold LOCK_worker, and tear the THD down from a local
copy afterwards. abort_worker() reads this member under the same lock and
calls awake() on it, so it has to see either a THD that is still alive --
the lock keeps us out of the teardown below until it has -- or nullptr.
Leaving the member set would let it awake() a THD this thread has already
destroyed.
*/
THD *worker_thd= thd;
thd= nullptr;
mysql_mutex_unlock(&LOCK_worker);
// This is similar to what destroy_worker_thd() does:
server_threads.erase(worker_thd);
/*
executing thd_detach_thd sets my_thread_var to null, stopping our ability
use the normal mutex mechanisms, so we operate this outside the locked
region on a copy of our THD pointer
*/
thd_detach_thd(save);
destroy_background_thd(worker_thd);
}
/**
@brief
Empty warning/error queue from workers into our manager thread
@description
Surface errors/warnings the workers queued via PWT_error_handler. A worker
error that mattered to the result has already aborted the join during
execution (fatal_error or a propagated kill), so thd is already in error by
the time we get here; raising another error would trip the "can't overwrite
status" assertion in the diagnostics area. So only raise a queued ERROR
when thd is not already in error -- otherwise keep it as a warning. Plain
warnings are always safe to add.
@parameters
skip_interrupted whether or not to ignore a deluge of interrupted
errors
*/
void pwt_thread_manager::process_pending_warnings(bool skip_interrupted)
{
bool surface_drop;
mysql_mutex_lock(&LOCK_pwt_manager);
surface_drop= messages_dropped;
messages_dropped= false;
pwt_queued_event *event;
while ((event= parallel_messages.get()))
{
if (pwt_error_message *err= event->error)
{
// if called during initialization, skip processing
if (!skip_interrupted || err->code != ER_QUERY_INTERRUPTED)
{
if (err->level == Sql_condition::enum_warning_level::WARN_LEVEL_ERROR &&
!thd->is_error())
my_message_sql(err->code, err->message, MYF(0));
else
push_warning(thd, Sql_condition::WARN_LEVEL_WARN, err->code,
err->message);
}
my_free(err->message);
my_free(err);
}
my_free(event);
}
mysql_mutex_unlock(&LOCK_pwt_manager);
if (surface_drop)
{
push_warning_printf(thd, Sql_condition::WARN_LEVEL_WARN, ER_OUTOFMEMORY,
"Parallel worker diagnostics were dropped due to "
"memory allocation failure");
}
}
bool pwt_thread::init_worker_thd(THD *parent_thd, int worker_nr)
{
/* First, do things that may fail early. */
LEX_CSTRING new_db;
if (parent_thd->db.str)
{
// Explicit call in ~THD/THD::free_connection()/my_free, so we do this
if (!(new_db.str= my_strndup(key_memory_pwt_db, parent_thd->db.str,
parent_thd->db.length,
MYF(MY_WME | ME_FATAL))))
return true; // OOM
new_db.length= parent_thd->db.length;
}
else
{
new_db.str= nullptr;
new_db.length= 0;
}
thd= create_background_thd();
if (!thd)
{
my_free(const_cast<char*>(new_db.str));
my_error(ER_INTERNAL_ERROR, MYF(0),
"init_parallel_workers: failed to create worker thread THD");
return true; // Failed
}
thd->db= new_db;
mysql_mutex_init(key_mutex_pwt_LOCK_worker, &LOCK_worker,
MY_MUTEX_INIT_FAST);
//mysql_cond_init(key_COND_pwt_worker, &COND_worker, nullptr);
thd->system_thread= SYSTEM_THREAD_GENERIC;
size_t len= my_snprintf(info.conn_name, MAX_THREAD_NAME,
WORKER_NAME);
thd->connection_name.str= info.conn_name;
thd->connection_name.length= len;
thd->security_ctx= parent_thd->security_ctx;
thd->set_command(parent_thd->get_command());
thd->start_utime= parent_thd->start_utime;
/*
the worker THD now takes the session's variables instead of
keeping the global defaults create_background_thd() gave it.
Some items use these during evaluation, such as TIMESTAMP, which
looks up variables.time_zone.
Done this strange way so that plugin_thdvar_cleanup() cleans itself
up and not it's manager.
The list below is plugin_thdvar_cleanup()'s own: whatever it frees, this
has to give back, or the worker frees the manager's buffer and the second
THD to go down frees it again. Keep the two in step -- a member added there
and not here is a double free, and it shows up a long way from either, in
safemalloc's marker check while a worker THD is being destroyed.
Saved field by field because system_variables has no copy constructor,
only assignment (Sql_path declares one and not the other).
*/
plugin_ref o_table_plugin= thd->variables.table_plugin;
plugin_ref o_tmp_table_plugin= thd->variables.tmp_table_plugin;
plugin_ref o_enforced_table_plugin= thd->variables.enforced_table_plugin;
ulong o_dynvar_version= thd->variables.dynamic_variables_version;
char *o_dynvar_ptr= thd->variables.dynamic_variables_ptr;
uint o_dynvar_head= thd->variables.dynamic_variables_head;
uint o_dynvar_size= thd->variables.dynamic_variables_size;
char *o_track_sysvars=
thd->variables.session_track_system_variables;
char *o_redirect_url= thd->variables.redirect_url;
char *o_replay_context= thd->variables.optimizer_replay_context;
LEX_CSTRING o_master_conn= thd->variables.default_master_connection;
ulonglong o_option_bits= thd->variables.option_bits;
my_bool o_sql_log_bin= thd->variables.sql_log_bin;
ulong o_binlog_format= thd->variables.binlog_format;
my_thread_id o_pseudo_thread_id= thd->variables.pseudo_thread_id;
thd->variables= parent_thd->variables;
thd->variables.table_plugin= o_table_plugin;
thd->variables.tmp_table_plugin= o_tmp_table_plugin;
thd->variables.enforced_table_plugin= o_enforced_table_plugin;
thd->variables.dynamic_variables_version= o_dynvar_version;
thd->variables.dynamic_variables_ptr= o_dynvar_ptr;
thd->variables.dynamic_variables_head= o_dynvar_head;
thd->variables.dynamic_variables_size= o_dynvar_size;
thd->variables.session_track_system_variables= o_track_sysvars;
thd->variables.redirect_url= o_redirect_url;
thd->variables.optimizer_replay_context= o_replay_context;
thd->variables.default_master_connection= o_master_conn;
thd->variables.option_bits= o_option_bits;
thd->variables.sql_log_bin= o_sql_log_bin;
thd->variables.binlog_format= o_binlog_format;
thd->variables.pseudo_thread_id= o_pseudo_thread_id;
/*
A worker evaluates this session's expressions, so it is running this
session's query and has to say so. Items that hold a value for the
duration of one statement keep it against the query id they computed it
under: Item_func_curtime, and the NOW/SYSDATE family with it, recomputes
only when thd->query_id has moved past its own last_query_id. A
background THD starts at query id 0, which is also the value that field
is born with, so a copy that had not been evaluated before it was cloned
looked up to date to the worker and handed out its uninitialized
MYSQL_TIME -- what the is_valid_value_slow() assertion in Time::Time()
catches.
*/
thd->query_id= parent_thd->query_id;
thd->thread_id= next_thread_id();
my_snprintf(info.process_list, sizeof(info.process_list),
WORKER_NAME " %u " CONNECTION_NAME_THREAD " %llu",
worker_nr, parent_thd->thread_id);
thd->query_string= CSET_STRING(info.process_list,
strlen(info.process_list),
thd->query_charset());
thd->userstat_running= parent_thd->userstat_running;
// TODO: it is OK that we insert before starting the OS thread, right?
/*
Visible in the processlist before its thread exists, and so killable before
it exists. That is safe, and worth saying why rather than leaving it as a
question: create_background_thd() returns a fully built THD, and thread_id
and query_string are set above, so nothing here is half-made. A KILL that
lands in the window sets thd->killed and little else -- system_thread is
SYSTEM_THREAD_GENERIC so awake() leaves mysys_var->abort alone, there is no
condition to signal yet, and no engine transaction to interrupt -- and the
worker acts on it as soon as it starts, which is what we want it to do.
What the window costs is that every path out of here has to erase it again.
release_worker() is that path, and it is the only one.
*/
server_threads.insert(thd); // +information_schema.processlist
/*
From here the worker holds a THD and LOCK_worker, and only release_worker()
gives them back. Nothing between the mutex init above and this line may
fail, or the flag would not cover what was taken.
*/
inited= true;
return false; // Ok
}
/*
Destroy the THD init_worker_thd() built, for a worker whose thread never ran.
Nulls it, so it stays true that a null thd means nobody has one to destroy.
*/
void pwt_thread::destroy_worker_thd()
{
if (!thd)
return;
server_threads.erase(thd);
/*
destroy_background_thd() requires current_thd to be NULL because it
re-attaches the background THD to this thread's TLS. We are running on
the user's query thread (current_thd == manager thd), so save/null/
restore around the call. Mirrors the create_background_thd() pattern.
*/
THD *save_thd= current_thd;
set_current_thd(nullptr);
destroy_background_thd(thd);
set_current_thd(save_thd);
thd= nullptr;
}
/*
@brief
Give back everything this worker holds. See reap_worker()/abort_worker().
@description
Three stages of startup, one teardown:
not initialised nothing was taken, so there is nothing to give back
no thread started the THD is still ours to destroy, and so is whatever
a run would have released
thread started wait for it; it destroyed its own THD on the way out,
which is what nulling thd told us
*/
void pwt_thread::release_worker(bool abort)
{
if (!inited)
return;
if (thread_started)
{
if (abort)
{
/*
The worker may already be tearing itself down: init_and_run_thread_func
nulls thd and destroys the THD under LOCK_worker. Take that lock and
only awake() if it has not entered its exit section yet; if it has, it
is on its way out and the join below reaps it.
*/
mysql_mutex_lock(&LOCK_worker);
if (thd)
thd->awake(ABORT_QUERY);
mysql_mutex_unlock(&LOCK_worker);
}
pthread_join(pthread, nullptr);
thread_started= false;
DBUG_ASSERT(!thd); // it destroyed its own before it exited
// TODO: assert that the master has got the error condition?
}
else
{
cleanup_without_run();
destroy_worker_thd();
}
mysql_mutex_destroy(&LOCK_worker);
// mysql_cond_destroy(&COND_worker);
inited= false;
}
void pwt_thread_with_stats::thread_func_end()
{
/*
Hand our status counters to the manager, which adds them to the session's
own once every worker has been joined. Everything we counted was done for
the user's statement, so it belongs in that session, and ~THD would
otherwise put it straight into the global counters and nowhere else, leaving
SHOW SESSION STATUS short by whatever the workers did. Clearing them here
stops ~THD adding the same numbers to the global counters a second time,
once the manager's session passes them on.
Memory accounting stays with this THD, because more of this THD's memory is
freed after this point and ~THD has to reconcile the whole of it with the
global counters -- clear_for_flush_status is the offset that leaves those
fields alone, and the snapshot drops its copy of it.
Temporary-file space does move, and has to. temp_file_size_cb_func() charges
whichever thread grows an on-disk temp table and credits whichever thread
shrinks or frees it, and for a worker's container those are this thread and
the manager. Left here, the balance would trip ~THD's assertion on the way
out; left here and then credited to the manager at free time, it would take
the manager's below zero. So hand it over with the counters, and zero it
here -- add_to_status() in quiesce_workers() puts it where the credit will
land, and that runs before free_containers().
*/
stats= thd->status_var;
stats.global_memory_used= 0;
thd->set_status_var_init(clear_for_flush_status);
thd->status_var.tmp_space_used= 0;
pwt_thread::thread_func_end();
}
pwt_thread_manager::pwt_thread_manager() :
messages_dropped(false)
{
mysql_mutex_init(key_mutex_pwt_LOCK_manager, &LOCK_pwt_manager,
MY_MUTEX_INIT_SLOW);
}
pwt_thread_manager::~pwt_thread_manager()
{
mysql_mutex_destroy(&LOCK_pwt_manager);
}