Skip to content

RFC: non-blocking behavior for next_result - #807

Closed
osheroff wants to merge 3 commits into
brianmario:0.3.xfrom
osheroff:gvl_next_result
Closed

osheroff wants to merge 3 commits into
brianmario:0.3.xfrom
osheroff:gvl_next_result

Conversation

@osheroff

Copy link
Copy Markdown

We were a multi-statement query in our, and discovered that when queries other than the first query blocked, (SELECT 1; SELECT SLEEP(3)) the call to next_result became completely uninterruptible. This was fixed well enough by by wrapping the next_result call in rb_thread_call_without_gvl, but we subsequently found that the next_result call still wouldn't respond to Thread#raise, which we needed in order to perform some timeout logic.

Went down the rabbit hole further to find that the vio suite of functions will over-read a buffer, so mysql2's methodology of calling rb_wait_for_single_fd really won't work reliably for next_result -- reading the first result in the do_query path will trigger a read of the result of the results (if they're ready), meaning that we'll poll forever; ugly. I worked around this by directly calling vio_has_data, but in order to do that I had to include all of violite.h, and I can't say I'm particularly happy about it.

Anyway, we probably are simply backing off our using of multi-statement selects, if you wanted to merge this (or had better ideas about how to properly poll for results when the c client library had buffered stuff), great.

(note, PR targets 0.3 just because thats what we're on still).

Ben Osheroff added 3 commits November 17, 2016 05:52
when actually preforming a multi-statement select with any non-trivial
components, the `next_result` call will block the ruby interpreter.
We fix this in two ways:

First, we wrap up the actual mysql_next_result call in a "no-gvl"
call so that the interpreter can do other work while next_result is
happening.  Second, we borrow a method from do_query and spin in a
select() loop on the mysql socket until the query is ready.  A quick
source dive into the underlying mysql source shows that next_result()
falls into the same path as mysql_real_query (cli_read_query_result), so
this should be a safe method.
@sodabrew

Copy link
Copy Markdown
Collaborator

Wow! This'll take me a bit to read through, thanks for the thorough research and PR!

Comment thread ext/mysql2/client.c
}
}

static struct timeval *get_read_timeout(VALUE self, struct timeval *tvp)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this function returns it returns the pointer from the tvp argument?

Comment thread ext/mysql2/client.c
@@ -563,23 +578,20 @@ static VALUE do_query(void *args) {
rb_raise(cMysql2Error, "read_timeout must be a positive integer, you passed %ld", sec);
}
tvp->tv_usec = 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While you're in here, would you move the tv_usec = 0 to be directly below the tv_sec = sec line?

Comment thread ext/mysql2/client.c
* the underlying mysql client library will have read both results into the buffer,
* defeating our attempt to poll() until ready. detect that case by peeking at the "vio" buffer.
*/
if ( !wrapper->client->net.vio->has_data(wrapper->client->net.vio) ) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is has_data the only method that required the violite.h headers?

Comment thread ext/mysql2/client.c
int retval;

for(;;) {
retval = rb_wait_for_single_fd(fd, RB_WAITFD_IN, tvp);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was surprised, so I double-checked that rb_wait_for_single_fd does allow a NULL timeval argument. Could you add a comment to let the future coders know that NULL can get through from get_read_timeout and is a legal value for this function?

https://github.com/ruby/ruby/search?utf8=%E2%9C%93&q=rb_wait_for_single_fd

Comment thread ext/mysql2/violite.h
@@ -0,0 +1,280 @@
/* Copyright (c) 2000, 2015, Oracle and/or its affiliates. All rights

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking more closely, it's a little worse than just defining the one has_data method, we're actually becoming aware of the st_vio structure in order to call one of its member function pointers. I'm concerned that this isn't part of the "public API" and would be subject to change across MySQL versions.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's talk through this one before I address the rest of the stuff. Yes, you're definitely right that this isn't part of the public api; I'm not particularly happy with the solution, but I looked for quite a bit and couldn't find any function, public or not, that would advise you about whether you had to block.

Having said that, the history of this structure is that it hasn't had a breaking change since (maybe) 2104 or (probably) 2012: https://github.com/mysql/mysql-server/commits/5.7/include/violite.h

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the other possibility here is to declare dependency on the two functions that the has_next pointer could point to, vio_buff_has_data or vio_ssl_has_data, but this would mean figuring out whether the underlying connection has negotiated an SSL connection or not, and it's not clear to me if we can figure that out.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, that's a great lead. Let's see if there's a way to determine the connection type. It might be nice to expose that information, too like Mysql2::Client#ssl_connection?

@sodabrew

sodabrew commented Jan 3, 2017

Copy link
Copy Markdown
Collaborator

A while back I thought about changing the multiple results API a bit to help support streaming on the 2nd - nth queries in a multiple statements, see #600 and let me know what you think of the idea proposed there.

@sodabrew

Copy link
Copy Markdown
Collaborator

I want to pick up work on this PR again soon, but I prefer to bring it into a next major (0.5.0) rather than making major changes to 0.3.x or even 0.4.x now. Would you be amenable to rebasing your branch to master?

(note, PR targets 0.3 just because thats what we're on still)

@sodabrew sodabrew added this to the 0.5.0 milestone Apr 26, 2017
@sodabrew sodabrew modified the milestones: 0.5.0, 0.6.0, 0.5.1 Mar 19, 2018
@sodabrew sodabrew modified the milestones: 0.5.1, 0.5.2 Apr 7, 2018
@sodabrew

Copy link
Copy Markdown
Collaborator

Closing in favor of rebase #962

@sodabrew sodabrew closed this Apr 14, 2018
@sodabrew sodabrew modified the milestones: 0.5.2, 0.5.3 Jul 4, 2018
sodabrew added a commit to sodabrew/mysql2 that referenced this pull request Aug 13, 2026
…rianmario#1033

client.next_result calls mysql_next_result() directly. When the next
statement in a multi-statement batch isn't ready yet, that call makes
a real blocking network read -- unlike the initial query, which
already releases the GVL and polls an interruptible fd wait (see
do_query/wait_for_fd in client.c), next_result does neither.

Bugs:
- The GVL is held for the entire wait: a second Ruby thread gets zero
  scheduling opportunities while next_result blocks on a slow
  statement, confirmed by counting ticks from a sleeping counter
  thread during the wait.
- The call is not interruptible: Timeout.timeout around next_result
  does not raise, because the underlying blocking C call can't
  respond to Thread#raise. The timeout is silently absorbed and the
  call returns normally after the full wait.

Notes:
- Both tests currently fail, by design -- that failure is the
  reproduction. They're written as assertions of correct behavior and
  are expected to start passing once a fix lands later on this same
  branch.
- See the comment on brianmario#1033 for the full history: brianmario#807 (2016) and its
  rebase brianmario#962 (2018) attempted a fix via GVL release plus a
  vio-internals probe for already-buffered data, but neither merged.
  brianmario#962 is now closed as a historical artifact; this branch starts
  fresh from a reproduction, then a survey of what current
  libmysqlclient/MariaDB Connector/C offer for this.
sodabrew added a commit to sodabrew/mysql2 that referenced this pull request Aug 13, 2026
…Thread#raise

client.next_result called mysql_next_result() directly. When the next
statement in a multi-statement batch (SELECT 1; SELECT SLEEP(3)) isn't
ready yet, that's a real blocking network read -- unlike the initial
query, which already releases the GVL and polls an interruptible fd
wait (do_query/wait_for_fd), next_result did neither. brianmario#807 (2016) and
its rebase brianmario#962 (2018) tried to fix this by reaching into
vio_priv.h to detect already-buffered data before deciding whether to
poll; neither merged, and the underlying bug (brianmario#1033) stayed open.

Bugs:
- The GVL was held for the entire wait: a second Ruby thread got zero
  scheduling opportunities while next_result blocked on a slow
  statement.
- The call was not interruptible: Timeout.timeout around next_result
  did not raise, because the blocking C call couldn't respond to
  Thread#raise. The timeout was silently absorbed and the call
  returned normally after the full wait.

Fixes:
- On MySQL 8.0.16+, poll mysql_next_result_nonblocking() (a real
  libmysqlclient API added years after brianmario#807/brianmario#962 were written)
  instead of calling mysql_next_result() directly. Each iteration
  either completes immediately or reports NET_ASYNC_NOT_READY, in
  which case the wait -- not the poll -- is what actually blocks, via
  the same rb_wait_for_single_fd machinery do_query already uses, so
  it's interruptible the same way. Mixing this with the ordinary
  blocking API elsewhere on the same connection is explicitly
  documented as supported by MySQL, so nothing else in this file
  needed to change, and brianmario#962's vio-internals detection for
  already-buffered data is no longer needed at all -- the nonblocking
  function itself reports that.
- Falls back to releasing the GVL around the still-blocking call
  (rb_thread_call_without_gvl) on MariaDB and on MySQL older than
  8.0.16, guarded by have_func('mysql_next_result_nonblocking').
  This fixes the GVL-holding half of the bug unconditionally; the
  interruptibility half depends on whether the linked libmysqlclient
  retries internally on the signal RUBY_UBF_IO sends on EINTR, which
  this fallback can't control. Verified empirically on this machine's
  MySQL 8.0.34 build with the fast path force-disabled: both problems
  actually resolved here too, but that isn't guaranteed everywhere.

Notes:
- Sets wrapper->state to QUERYING for the duration and reaps on the
  way out (rb_ensure), matching the QUERYING/IDLE bracket rb_mysql_query
  already uses. This didn't matter before: holding the GVL for the
  entire call incidentally serialized against any other thread's
  query/prepare/statement-close on this connection. Fixing the
  GVL-holding half of the bug removes that accidental protection, so
  the explicit bracket is now load-bearing, not just for consistency.
- Added REQUIRE_CONNECTED, which the original function didn't have --
  needed so a disconnected client can't reach the new poll loop with a
  stale/invalidated fd.
- MariaDB Connector/C has its own non-blocking pair for this
  (mysql_next_result_start/_cont, confirmed to exist by reading
  Connector/C's actual header), not yet wired up here. Whether it's
  safely mixable with ordinary blocking calls the way MySQL's version
  is, or requires a connection-wide MYSQL_OPT_NONBLOCK commitment, is
  still being verified before extending this fix to that path.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants