Skip to main content

Fixed: A Request Never Answers When a MySQL Statement Runs Longer Than CommandTimeout (MySql.Data)

· 8 min read
Jagdish Kumawat
Founder @ Dewiride

I set a short CommandTimeout, expecting an error when a statement ran too long. Instead, the request never answered at all. MySQL had finished long ago, and the connection sat idle, out of reach of everything else. If you're on Oracle's MySql.Data, any async statement slower than CommandTimeout can do this, including a default of 30 seconds behind a row lock.

What I Was Doing​

My .NET C# Web API uses EF Core 10.0.14, MySql.EntityFrameworkCore 10.0.12 and MySql.Data 26.10.0. I wanted statements to give up after 2 seconds:

src/Dewiride.Inventory.Data/InventoryDataServiceCollectionExtensions.cs (excerpt)
mySql.CommandTimeout(database.CommandTimeoutSeconds);

One endpoint runs a statement that takes a few seconds:

An endpoint
g.MapGet("/slow/{seconds:int}", async (int seconds, InventoryDbContext db, CancellationToken cancellationToken) =>
{
var result = await db.Database
.SqlQuery<long>($"SELECT SLEEP({seconds}) AS Value")
.ToListAsync(cancellationToken);

return Results.Ok(new { result = result[0] });
});

I called it for 5 seconds, and told curl to stop waiting after 15 and print what happened:

Terminal
curl -s -m 15 -w "\ncurl exit %{exitcode}, %{time_total} s, status %{http_code}\n" http://localhost:5190/demo/slow/5

What I Expected​

An error after 2 seconds. .NET's DbCommand.CommandTimeout "Gets or sets the wait time (in seconds) before terminating the attempt to execute the command and generating an error."

What I Got​

No answer, for as long as I waited:

Output
curl exit 28, 15.006849 s, status 000

Exit code 28 is curl giving up at its own limit, and status 000 means no answer ever arrived.

curl giving up after 15 seconds with exit code 28 and status 000, because no answer arrived.

What I Checked​

What MySQL was doing. Nothing, and that was the strange part:

mysql: root session
SELECT PROCESSLIST_ID, PROCESSLIST_COMMAND, PROCESSLIST_TIME, LEFT(PROCESSLIST_INFO,40) AS statement
FROM performance_schema.threads
WHERE PROCESSLIST_USER='inventory_app';
Output
+----------------+---------------------+------------------+-----------+
| PROCESSLIST_ID | PROCESSLIST_COMMAND | PROCESSLIST_TIME | statement |
+----------------+---------------------+------------------+-----------+
| 12 | Sleep | 15 | NULL |
+----------------+---------------------+------------------+-----------+

The statement had finished and the connection was idle, while the API still waited.

When the driver acts. The general query log has the timing:

Output (the statement, and the driver's KILL)
+-----------------+------------+--------------------------+
| time | connection | statement |
+-----------------+------------+--------------------------+
| 05:34:40.999536 | 19 | SELECT SLEEP(5) AS Value |
| 05:34:46.044759 | 20 | KILL QUERY 19 |
+-----------------+------------+--------------------------+

The SLEEP(5) started at :40, and KILL QUERY came at :46. So the 2-second timer didn't fire at 2 seconds. The driver acted only once the 5-second answer had arrived, and by then there was nothing to kill.

The same statement without async.

Output (the sync version, slow-sync)
curl exit 0, 2.061392 s, status 500

Sync fails at 2 seconds, as documented, and the statement then keeps running on the server:

Output (MySQL's list a moment later)
+----------------+---------------------+------------------+--------------------------+
| PROCESSLIST_ID | PROCESSLIST_COMMAND | PROCESSLIST_TIME | statement |
+----------------+---------------------+------------------+--------------------------+
| 12 | Sleep | 30 | NULL |
| 19 | Sleep | 15 | NULL |
| 23 | Query | 2 | SELECT SLEEP(5) AS Value |
+----------------+---------------------+------------------+--------------------------+

TLS. With SslMode=Disabled, the result was the same, so it isn't to do with encryption.

The driver's source. In TimedStream.cs, at tag 26.10.0:

TimedStream.cs (Oracle's source, tag 26.10.0)
private async Task<int> ReadInternalAsync(byte[] buffer, int offset, int count)
{
try
{
StartTimer(IOKind.Read);
int retval = await _baseStream.ReadAsync(buffer, offset, count).ConfigureAwait(false);
StopTimer();
return retval;
}

Why It Happens​

From the source and the timing above, here's how I read it. The sync path sets a timeout on the read. The async path awaits ReadAsync with no timeout, so it waits for the whole statement. Then StopTimer sees that too much time has passed, and raises a TimeoutException, though the answer has already arrived. The handler sends KILL QUERY for a statement that has finished, then tries to read the reply to it, and no reply is coming. So it waits forever.

That has two more consequences, and both were real here.

The connection never returns to the pool. Each stuck request keeps one. Fixed: Timeout expired, prior to obtaining a connection from the pool shows the app running out.

In a transaction, it keeps its locks. A stuck order held its row, and the next order for that product waited behind it:

Output (information_schema.innodb_trx, with a second order waiting)
+---------------------+-----------+--------------+-----------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| trx_mysql_thread_id | trx_state | open_seconds | trx_rows_locked | trx_query |
+---------------------+-----------+--------------+-----------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| 42 | LOCK WAIT | 22 | 1 | UPDATE `products` AS `p`
SET `QuantityOnHand` = `p`.`QuantityOnHand` - 1,
`UpdatedAtUtc` = timestamp('2026-11-05 05:19:21.491337')
WHERE ((`p`.`Id` = 2) AND NOT (`p`.`IsArchived`)) AND (`p`.`QuantityOnHand` >= 1) |
| 37 | RUNNING | 30 | 1 | NULL |
+---------------------+-----------+--------------+-----------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
2 rows in set (0.002 sec)

One slow statement froze that product until the API restarted.

The Fix​

Take the time limit out of the driver, and put it on the server, where it works:

src/Dewiride.Inventory.Data/InventoryDataServiceCollectionExtensions.cs (excerpt)
// No time limit in the driver: an async statement that runs past it never returns.
// MySqlSessionLimitsInterceptor sets the limits on the server instead.
mySql.CommandTimeout(0);

0 means no limit, so the read simply waits for the answer. MySQL then does the limiting, with two settings:

SettingWhat it stopsError
innodb_lock_wait_timeoutA statement waiting for a row lock1205
max_execution_timeA SELECT that runs too long3024

How to Run SQL When a Connection Opens in EF Core sets both, on every connection.

After the Fix​

With CommandTimeout(0), the same 5-second statement answers normally:

Output
{"result":0}
curl exit 0, 5.067046 s, status 200

And a statement behind a lock now gets a clear refusal at 5 seconds, from MySQL:

API log
fail: Microsoft.EntityFrameworkCore.Database.Command[20102]
Failed executing DbCommand (5,043ms) [Parameters=[@line_Quantity='?' (DbType = Int32), @p='?' (DbType = DateTime), @line_ProductId='?' (DbType = Int32)], CommandType='Text', CommandTimeout='0']
UPDATE `products` AS `p`
SET `QuantityOnHand` = `p`.`QuantityOnHand` - @line_Quantity,
`UpdatedAtUtc` = @p
WHERE ((`p`.`Id` = @line_ProductId) AND NOT (`p`.`IsArchived`)) AND (`p`.`QuantityOnHand` >= @line_Quantity)

The same request answering 200 after 5.067 seconds with CommandTimeout set to 0.

A timeout that's longer than the statement also avoids the hang, so CommandTimeout(10) answered this one at 5 seconds as well. The trouble with relying on that is the first statement that's slower than you planned.

Common Mistakes​

MistakeWhat happensDo this instead
Leaving the default of 30 seconds, with a lock wait limit of 50A row held for over 30 seconds hangs the request for goodCommandTimeout(0), and a short lock wait limit
Relying on the client to hang upThe cancellation doesn't reach the stuck readLimit it on the server
Testing only short statementsThe hang never showsTest with a held row lock
Restarting the API, and calling it fixedIt comes back with the next slow statementFix the setting

Troubleshooting​

SymptomLikely causeFix
A request hangs, and MySQL shows the connection in SleepThisThe fix above
KILL QUERY in the general log with no statement runningThisThe fix above
Timeout expired ... prior to obtaining a connection from the poolHung requests kept the connectionsThe fix
The hang goes away with sync callsThe sync path honours the timeout, and blocks a thread insteadDon't switch to sync. Use the fix above

FAQ​

Is a statement with no limit dangerous?​

An async read here had no real limit anyway. With CommandTimeout(0) and the two server settings, blocked writes and slow reads are both limited, and a slow write that's really running gets to finish.

Is this fixed in a newer version?​

I tested MySql.Data 26.10.0, the newest release when I wrote this. After an upgrade, look at ReadInternalAsync in the new tag's source, or try one statement that outlasts a short CommandTimeout.

Conclusion​

With MySql.Data, an async statement that outlasts CommandTimeout never returns, keeps its connection, and keeps its locks in a transaction. Use CommandTimeout(0), and set innodb_lock_wait_timeout and max_execution_time on the connection instead.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.