perf(plugin-mysql): bound a capped read at the server with SQL_SELECT_LIMIT (#2427) - #2447
Merged
Merged
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2427.
What is actually wrong
TablePro bounds a large MySQL or MariaDB read on the client, then tries to stop the server through a side channel: a brand-new connection issuing
KILL QUERY. When that side channel is refused, misrouted or simply slow, the whole result set still crosses the network before the first row reaches the grid. The parameterized path never even tries.#2430 added the
executeBoundedQueryhook and its body claimed MySQL "already did ... Unchanged". It did not.grep executeBoundedQuery Plugins/MySQLDriverPlugin/finds nothing, so the PluginKit default returns nil and every MySQL query falls back toexecuteUserQuery. The claim was about the older client-side cap inexecuteUserQuery, which is the mechanism that is broken.The side channel is unavoidable given that design, and I measured why:
mysql_kill()on the live connection, mid-resultCommands out of syncmariadb_cancel(MYSQL *)Server has gone away)So a second connection genuinely is the only way to stop a result set that is already streaming. Which is the argument for never starting one.
MySQL and MariaDB expose a server-side row cap,
SQL_SELECT_LIMIT, and TablePro has never used it. It is what MySQL Connector/J implementsStatement.setMaxRows()with (ConnectionImpl.java:2388-2394), which is how DBeaver bounds a MySQL read on its default path.Measured, end to end, through the real plugin bundle
A Swift harness loads a built
MySQLDriver.tablepluginand calls the driver directly. Baseline is the plugin built from5f162aa79, the merge base. Local MariaDB 12.3.2, 2,000,000-row InnoDB table,SELECT * FROM big, cap 1,000, loopback.A server that allows the second connection:
executeBoundedQuerynil, the hook is deadcolumnMetaintactexecuteUserQuerySELECT ... LIMIT 1000(the ceiling)A server that refuses it (a user at
MAX_USER_CONNECTIONS 1, standing in for AWS RDS denying plainKILL, a proxy wheremysql_thread_idnames the wrong backend, or a connection limit):executeUserQueryThat 679 ms is 138 MB of rows nobody asked for. On a 100 Mbit link it is about 11 seconds, which is the reporter's "around 10 seconds".
Deleting the client-side drain loop would not have helped:
mysql_free_resulton an unbuffered result reads the tail itself, 0.813 s against 0.818 s. The cost belongs to the server still sending.The parameterized path was worse, because it applied the cap and then issued no
KILLand no drain at all, leavingmysql_stmt_closeto pay for everything: 875 ms, against 0.9 ms with the limit set.The change
MariaDBPluginConnectiongains one concept: a statement that arrives with a row cap asks the server to stop atcap + 1before it runs. The invariant that makes it safe is that arowCapalready means the caller discards rows past the cap, so a server-side limit is a no-op on what the caller sees; it only stops the rows being produced.MySQLSelectLimitStatement.swiftholds the pure statement builders and the two decisions worth testing: what to reconcile to, and what a fetch count means.reconcileSelectLimitruns before every statement on the primary handle, from the connection's serial queue, and moves its cached value only when the server confirms the change. Failing to install a cap costs only the client-side fallback and is logged. Failing to move one that is already installed throws, because running the statement under a stricter limit than it asked for and reporting the short result as complete is silent data loss.SETresets whatROW_COUNT()reports, soUPDATE t SET ...; SELECT ROW_COUNT()would have answered 0 rather than 3, measured on both engines. The test strips comments, string literals and quoted identifiers first, so a keyword spelled inside one is never read as syntax, andFROM DUALstill counts as no table.SQL_SELECT_LIMITis captured lazily, the first time this connection is about to take the variable over. At connect would be too early: the connection's startup commands run afterconnect()returns, so aSET SESSION SQL_SELECT_LIMITamong them would have been lost rather than restored.information_schemaquery on the same connection. Measured: it does apply to those. Back-to-back capped reads at one cap still issue oneSETand none after, which is Connector/J's own optimisation.killQueryOnServerand the drain survive but now fire only when the server ignored the limit: aCALL, or a statement carrying its own largerLIMIT, both measured uncapped by the variable.MySQLPluginDriver.executeBoundedQuerydelegates to the capped read, the one-line idiomKafkaPluginDriver.swift:200already ships for a driver bounded at its source. NotboundedQueryFromStream, which cannot carrycolumnMeta.Why
SQL_SELECT_LIMITis safe hereMeasured on both MariaDB 12.3.2 and MySQL 8.4.11, and committed as
scripts/check-mysql-select-limit.shso a server upgrade re-checks it rather than trusting this paragraph:SELECT,UNION [ALL],VALUES,TABLE t, aninformation_schemaSELECT.INSERT ... SELECT,CREATE TABLE ... SELECT,REPLACE ... SELECT. All copied every one of their rows with the limit set.LIMITalways wins, above and below the session value.SET SQL_SELECT_LIMIT = DEFAULTrestores. It is not transactional, soROLLBACKdoes not restore it andUSE <db>does not clear it; the reconcile is the only thing that touches it.Your SQL still reaches the server exactly as you wrote it. Nothing is rewritten.
Honest limit
This bounds the rows the server returns. It cannot make a blocking plan node finish sooner. On the same table,
ORDER BYon an unindexed column went 925 ms to 462 ms, andGROUP BYstayed at about 11.3 s for every variant including an explicitLIMIT, becausemysql_real_queryblocks before row 1. We do not have @Nisgrak's SQL. If it is aGROUP BY, this removes the transfer but not the wait, and that 11.3 s is uncomfortably close to the reported number, so the query would be worth seeing.Also fixed
killQueryOnServerset noMYSQL_OPT_PROTOCOL, while the primary connect setsMYSQL_PROTOCOL_TCP. Measured with the shipped library: with the host spelledlocalhostthe primary reacheslocalhost via TCP/IPand the kill connection reachesLocalhost via UNIX socket, ignoring theportargument entirely. Against a second local instance on another port it still went down the default socket, soKILL QUERY <threadId>landed on a different server, where that thread id belongs to somebody else's session. It now repeats the primary's transport and its cleartext-auth option, and logs a refused connect or a rejectedKILLinstead of discarding it silently.Rejected
LIMIT. TablePlus does this and it is TablePlus Connect to Oracle database, program bengk #1683 (a trailing--swallows it) and macOS Tests Gate is not a required check, so PRs merge red #2422 (a leading comment skips it).SET STATEMENT SQL_SELECT_LIMIT=n FOR <stmt>. Works and self-restores, but MariaDB-only, and still text rewriting.STMT_ATTR_CURSOR_TYPE=CURSOR_TYPE_READ_ONLYwithSTMT_ATTR_PREFETCH_ROWS), the other classic server-side bound. Measured:mysql_stmt_executealone costs 805 ms because the server materialises the whole result into an internal temporary table before returning. It makes the server do more work, not less.CLIENT_MULTI_STATEMENTSto fold theSETinto one round trip. The connection passesclient_flag = 0today, and widening the injection surface to save a round trip is a bad trade.Verification
verify.sh buildverify.sh test MySQLSelectLimitTests BoundedQueryTests PluginStreamAbortTests MySQLQueryTimeoutTests QueryExecutorTestsverify.sh lint Plugins/MySQLDriverPlugin TableProTests/PluginsMySQLDriverschemeshellcheck --severity=warning scripts/check-mysql-select-limit.shscripts/check-mysql-select-limit.shreviewadversarial-reviewNew tests:
MySQLSelectLimitTestsover the reconcile decision and the fetch-count outcome, including the case a reviewer caught in the draft, where leaving the client cap atcaprather thancap + 1would have kept theKILLfiring on every cappedSELECT. Two cases added toBoundedQueryTestsfor a driver bounded at its source, so the hook returning nil (the opt-in present but doing nothing) fails.No UI automation: the flow needs a live MariaDB with a multi-million-row table and no deterministic fixture exists for it. No screenshots: nothing visual changed, the row cap affordance already shipped.
swiftformatcould not run: the local binary rejects--ifdefindentfrom.swiftformat. SwiftLint is clean.No PluginKit change, so no ABI bump and no plugin re-release. MySQL is a bundled plugin and ships with the app.
What the review changed
Codex reviewed the diff and found five things. All five were real and all five are fixed; the measurements are in this PR because they came out of chasing them.
SETclobbersROW_COUNT(). Measured 3 to 0 on MariaDB 12.3.2 and MySQL 8.4.11. Fixed by not installing the cap on a statement that reads no row source.SHOW WARNINGSturned out to survive theSET, so that half of the finding did not hold.SELECTatoldCap + 1rows and report it complete. It now throws, and the cancellation gate is rechecked after the hiddenSET.SELECT COUNT(*) FROM (SELECT ...), whose one-row outer result is not bounded by the session limit, so it passed either way. It now counts rows the client actually received.argv. It goes through the environment now.A sixth came out of reviewing my own diff for the security pass: the kill connection had gained
MYSQL_ENABLE_CLEARTEXT_PLUGINwithout mirroring the primary's TLS, so cleartext auth could have gone over a plaintext channel.Then
adversarial-reviewattacked the result and returned "do not ship" with six more. All six were real:FROM: the draft skipped reconciliation entirely, the server ended at the old limit, and the client called the short result complete. The skip now applies only while the connection holds no limit of its own, so a misread statement can cost a redundantSETbut never a silent truncation.SELECT ROW_COUNT() FROM DUAL, andFROMinside a comment or a literal, defeated the first version of that test. It is now a proper scan that strips comments, strings and quoted identifiers, and it recognises set operations andVALUESrather than just looking forFROM..preferredis the default and falls back to plaintext, so on a server without TLS the primary would connect and everyKILLafter it would repeat the attempt that had already failed, breaking Stop. It now reproduces what the primary actually negotiated, read frommysql_get_ssl_cipher.LIMIT 1, since an unbounded probe returns no rows at all when the session limit is 0 and reads as a failed probe.tablepro_select_limit_checksurvives a run untouched.MYSQL_PWDonly, and TLS is negotiated normally unless--insecureis passed, which is refused for anything but a loopback host.The three behaviours those findings named are now measured against the baseline plugin, and match it:
ROW_COUNT()after anUPDATEof 3 rows returns 3,ROW_COUNT() FROM DUALreturns 3, and a 7-row constant query run after a cap of 5 returns 7 rows withtruncated=false.Found while investigating, not shipped here
Each was verified by an adversarial pass that tried to refute it, and each has a reproduction. None is a regression from this change.
DatabaseManageris@MainActorandcancelRunningQuery(reach: .userStop)is deliberately synchronous; for MySQL that runs a blockingmysql_real_connect. Measured against an unroutable host: 5,001 ms, theMYSQL_OPT_CONNECT_TIMEOUT. PostgreSQL'sPQcancelcosts 70-160 ms by comparison.MariaDBPluginConnection.swift:384.mysqlStatementIsReadOnlytreats everySELECTas replayable. After a dropped connectionexecuteWithReconnectre-runs it, soSELECT NEXTVAL(seq)burns a second sequence value andSELECT GET_LOCK(...)re-acquires.MySQLStatementClassification.swift:6.Libs/libmariadb_arm64.ais Connector/C 3.4.4 while the vendored header declaresMARIADB_PACKAGE_VERSION 3.4.8.[Unreleased]inCHANGELOG.mdcarries two### Fixedheadings.CLAUDE.mdsays a section appears at most once per version. Left alone here because restructuring it would conflict with every in-flight branch.https://claude.ai/code/session_01KsqHrFwJxUW6eWozYjT8JZ