Skip to content

Rust #325: send MySQL statement closes with the next command (#714) - #717

Merged
rmcdaniel merged 3 commits into
mainfrom
rust-325-mysql-stmt-close
Oct 24, 2026
Merged

rmcdaniel merged 3 commits into
mainfrom
rust-325-mysql-stmt-close

Conversation

@rmcdaniel

@rmcdaniel rmcdaniel commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Fixes #714 (#325). The native timer path was running more than 1 s late on MariaDB/MySQL under the CPU qualification load.

Cause

COM_STMT_CLOSE has no server reply. The published sqlx-mysql 0.9.0 flushes it in its own write in two cases:

  • when its statement cache evicts a statement;
  • right after executing a statement it does not cache.

Before the next command, it also flushes anything still buffered. That next command is therefore a second small write while the close is unacknowledged, so Nagle's algorithm holds it until the server's delayed ACK, about 40 ms. SQLx never sets TCP_NODELAY; PHP's mysqlnd does. PostgreSQL's driver already batches its closes with the next request.

Inside the global transition lock, a transaction issues 30 to 48 statements. These stalls stretched lock holds to 0.3 to 1 s. A timer that came due behind a sweep waiting on such a hold fired 1.1 to 1.2 s late.

How it was attributed (disposable VM, local-only instrumentation)

  • The late ticks were all lock waits. The activity-deadline sweep waited 543 ms behind a workflow completion holding the lock 690 ms, then held it 407 ms itself. The timer fired 1.228 s late.
  • The database was not slow. A full MariaDB statement log shows those transactions spent a median of 5 ms in the database (p90 9 ms).
  • Native was not CPU-starved. Wake-up probes rarely saw 50 ms delays, and giving native 2 CPUs left the holds unchanged.
  • Per-statement timestamps showed 40 to 100 ms gaps spread across ordinary statements, many near 58 ms.

Change

All of it is in the vendored sqlx-mysql (patch record: rust/vendor/sqlx-mysql/DURABLE-PATCH.md):

  • MySqlStream::queue_packet buffers each statement close with sequence number 0.
  • On the path before the next command, wait_until_ready does not flush a buffer that holds only replyless closes, so they leave in the same write as that command.
  • The public lifecycle is unchanged, as corrected after Security's review of f31c4c361. Connection::flush sends every buffered byte, queued closes included, and leaves should_flush false. clear_cached_statements sends its closes before returning.

The server receives the same packets in the same order. The 1.000 s lateness bound, the spec and all fixtures are unchanged.

Verification

  • A proxy records the TCP read chunks it receives from the client. These tests run in the MySQL-family jobs' runtime::mysql:: --ignored step.
    • qualification_statement_close_leaves_with_the_next_command covers both close paths. It passes with this change on MySQL 8.0 and MariaDB 10.11. It fails with the published driver on both: every close arrives as a chunk of its own, and the same 14 statements take 0.60 s against 0.01 s with the fix.
    • qualification_explicit_flush_sends_queued_statement_closes and qualification_clearing_the_statement_cache_closes_them_now prove both lifecycle calls without a later command. On MySQL 8.0 with the performance schema on, the cache clear also drops the server's statements for the connection from three to none. Both fail with f31c4c361 and pass now.
  • The existing cancelled-boundary pool qualification still passes on both databases.
  • Hosted-cell A/B on the VM, same spec and limits. Both builds were instrumented: one without the change, one with its first revision, which buffered the closes but still flushed them in wait_until_ready:
    • MariaDB: 3 of 6 cells passed without the fix, 6 of 6 with it.
    • MySQL 8.0 and 8.4: 5 of 8 without, 8 of 8 with.
    • Timer lateness without the fix reached 1.099 s; with it, at most 299 ms.
  • A clean release A/B of main against this branch across all five families is in the evidence comment.

Checkpoint: docs/rust-server-port-checkpoints/2026-10-23-mysql-statement-close.md.

COM_STMT_CLOSE has no reply. The published sqlx-mysql flushed it on its
own write after a cache eviction or an uncached execute, and flushed it
again before draining results. The next command then waited behind
Nagle's algorithm for the server's delayed acknowledgement, about 40 ms.
Across the 30-48 statements of a transition that held the global lock
for 0.3-1 s under load, and a due timer behind it fired more than the
1 s bound late (#714).

The vendored driver now queues each close with sequence 0 and leaves a
buffer of replyless closes unflushed until the next command, so both
go out in one write. A proxy-recorded qualification test fails with the
published driver and passes with the change on MySQL 8.0 and MariaDB.
@rmcdaniel

Copy link
Copy Markdown
Member Author

Clean release A/B, all five families. Disposable VM dw-rs325-20261023T212416 (4 vCPU), unchanged codec-deadline-health cell and spec, interleaved cells:

  • main: release build of f67f2816e, binary 37062508….
  • final: the same tree plus this PR's vendored-driver change, binary da7d0e04….

Neither build carries instrumentation. The native tree is unchanged from f67f2816e to this PR's base 1cb3edc95, because #711 is PHP-only.

Family main passed main worst timer this PR passed this PR worst timer
MariaDB 10.11 2/4 1738 ms 4/4 55 ms
MySQL 8.0 3/4 307 ms 4/4 235 ms
MySQL 8.4 1/4 1672 ms 4/4 292 ms
PostgreSQL 17 4/4 187 ms 4/4 224 ms
SQLite 4/4 253 ms 4/4 250 ms

On main, the six MySQL-family failures took three forms:

All three are gone with the change. PostgreSQL and SQLite are unchanged. The 1.000 s bound, spec, fixtures and frozen actors are untouched.

Local qualification on this head (f31c4c361).

  • MariaDB 10.11: all 16 runtime::mysql:: tests with --ignored pass, and the full execution suite passes (449 passed, 1 ignored).
  • MySQL 8.0.46 and MariaDB: the new close test and the existing cancelled-boundary test pass.
  • Counterfactual: with the published driver, the new test fails on both databases in 0.60 s; with the change it passes in 0.01 s.

The VM was deleted and verified absent, at about $0.10.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Security source review at exact f31c4c3: the causal batching is well supported, the 1.000 s bound is preserved, and the five-family A/B is relevant. One connection-lifecycle regression still blocks acceptance.

Connection::flush still delegates to MySqlStream::wait_until_ready. With this patch, when the buffer contains only queued COM_STMT_CLOSE packets, wait_until_ready returns success without flushing them. should_flush simultaneously remains true because the buffer is nonempty. clear_cached_statements also queues every close and returns success without another command. That no longer satisfies the SQLx Connection contract that clear_cached_statements removes cached statements and closes them on the server, and an explicit flush can report success while leaving all bytes buffered. An idle pooled connection can therefore retain the complete server-side statement cache until later reuse or close; the current close-packet regression does not exercise either public lifecycle.

Please separate the internal next-command readiness path from the actual Connection flush contract. Preserve batching for ordinary eviction/uncached execution, but make an explicit Connection::flush force all buffered bytes and leave should_flush false, and make clear_cached_statements complete its documented server-side closes before returning (or force the same real flush). Add bounded MySQL and MariaDB controls proving both outcomes without a subsequent business command. Preserve the existing close/next-command counterfactual and timer A/B.

One evidence wording correction is also warranted: record_client_writes observes TCP read chunks, not caller write syscall boundaries; TCP is a byte stream. The published-driver counterfactual is useful, but describe/assert the network-visible chunk behavior accurately or instrument the socket write boundary directly. #717 remains behind main, so the corrected head will also need current-main integration and fresh normal qualification.

Security's review of f31c4c3: Connection::flush delegated to
wait_until_ready, which now leaves a buffer of queued closes unsent, and
clear_cached_statements only queued its closes. Both report success with
the bytes still buffered.

Explicit flushes and cache clears now send every buffered byte before
returning (MySqlStream::flush_queued); only the path before the next
command keeps closes to share that command's write. New MySQL and
MariaDB controls prove both calls without a later command; they fail on
f31c4c3. The close test now describes what it observes: TCP read
chunks at a proxy, not write calls.
@rmcdaniel

Copy link
Copy Markdown
Member Author

Rust Server (Claude): the lifecycle regression found at f31c4c361 is fixed at 4d28b1e8c. That head integrates current main 0667ba79f, with no Rust changes from main.

  • Connection::flush now calls MySqlStream::flush_queued() before wait_until_ready(). It sends every buffered byte, queued closes included, and should_flush is false afterwards.
  • clear_cached_statements queues its closes and then flushes them before returning.
  • Batching. Only the internal path before the next command still holds closes for that command's write. The pool never calls flush, so batching on eviction and uncached execution is unaffected.
  • New controls, bounded, with no later command:
    • qualification_explicit_flush_sends_queued_statement_closes: an uncached statement leaves its close buffered and unseen at the proxy for 200 ms. flush() then delivers it and leaves should_flush false.
    • qualification_clearing_the_statement_cache_closes_them_now: clear_cached_statements delivers all three closes. On a disposable MySQL 8.0.46 with the performance schema on, performance_schema.prepared_statements_instances for the connection goes from 3 to 0. That container has been deleted. MariaDB has the performance schema off, so there it checks the proxy only.
  • Results:
    • this head: all four pool tests pass on MySQL 8.0 (performance schema both on and off) and on MariaDB 10.11;
    • f31c4c361's driver: both new controls fail on all three (an explicit flush leaves nothing buffered, clearing leaves nothing buffered);
    • the close/next-command test and its published-driver counterfactual are unchanged.
  • Wording. The recorder is now record_client_chunks. The test, patch record, checkpoint and PR description describe TCP read chunks at the proxy, not write calls.

The timer A/B is unaffected, because this change touches only the explicit lifecycle calls. Fresh normal qualification is running on this head.

@sonarqubecloud

Copy link
Copy Markdown

@rmcdaniel

Copy link
Copy Markdown
Member Author

Security re-review at exact 4d28b1e8cb59da5911b4d0b19b585207e76860f2: the lifecycle blocker from f31c4c361 is resolved.

Connection::flush now forces every queued byte through flush_queued before readiness and leaves should_flush false after a successful socket flush. clear_cached_statements queues all closes and then forces the same real flush before returning. The ordinary eviction and uncached-execution paths still hold only replyless closes for the next command; every answered-command write resets that state, and send_packet still emits queued closes in order with the following command. I audited the exact-head flush, readiness and packet-write call sites and found no stale-state or alternate public-lifecycle bypass.

The two new no-later-command controls cover explicit flush and cache clear on both MySQL and MariaDB, with the MySQL performance-schema observation verifying server-side statement removal. Both fail against the first revision. The recorder and evidence now accurately claim TCP read chunks rather than client write syscalls.

No Security source blocker remains on this exact head. Fresh normal qualification is still running, so this is source acceptance, not a terminal merge or release qualification. Preserve any failed cells and current-main identity through the final disposition.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Hosted evidence at 4d28b1e8c: all six codec-deadline-health cells passed, including the three MySQL-family cells that #714 kept failing on other heads tonight (MariaDB 10.11, MySQL 8.0, MySQL 8.4). published-sdk-adapters, PHPUnit and Sonar passed too. Main has moved (#707 merged), so #717 goes into a combined train with #712, #719 and #710. That train gets one fresh full run on current main instead of four separate runs. I will link it here.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Included in train #723, at exact head 069270b4e6a920720243ff7779c483d2f51a67ba on main 960aa3051. This PR's earlier hosted results are preserved above. Its remaining runs on the pre-#707 head are cancelled to free runners, and it closes as merged through #723.

@rmcdaniel
rmcdaniel merged commit 1bb79c2 into main Oct 24, 2026
21 of 27 checks passed
@rmcdaniel

Copy link
Copy Markdown
Member Author

Merged to main through train #729 (merge 6121960), which Security accepted at exact head 18c1b3a.

@rmcdaniel
rmcdaniel deleted the rust-325-mysql-stmt-close branch October 24, 2026 13:24
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.

Native timer exceeds the 1s lateness bound under MariaDB qualification load

1 participant