Always reject read-only connections - #5
Conversation
Coverage Report for CI Build 34064857257Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage increased (+0.2%) to 84.767%Details
Uncovered ChangesNo uncovered changes found. Coverage Regressions1 previously-covered line in 1 file lost coverage.
Coverage Stats
💛 - Coveralls |
There was a problem hiding this comment.
🟢 Approval recommended
The behavior change is consistently implemented across DSN parsing, connection error handling, and transaction lifecycle, with targeted tests and documentation covering the new contract and exemption.
Pull request overview
This PR makes read-only connection rejection unconditional in this fork of the Go MySQL driver, removes the Config.RejectReadOnly API surface, and preserves DSN compatibility by accepting rejectReadOnly=true while erroring on rejectReadOnly=false. It also introduces an explicit carve-out for transactions opened with TxOptions.ReadOnly, so read-only errors in that case remain surfaced as *MySQLError rather than being converted into driver.ErrBadConn.
Changes:
- Make read-only error handling (errno 1792/1290/1836) always close the connection and return
driver.ErrBadConn, except during explicit read-only transactions. - Remove
Config.RejectReadOnlyand update DSN parsing sorejectReadOnly=trueis accepted (no-op) andrejectReadOnly=falsebecomes an error. - Add/adjust tests and documentation to pin the new behavior and the read-only-transaction exemption lifecycle.
File summaries
| File | Description |
|---|---|
| connection.go | Adds inReadOnlyTx state and sets it on successful BeginTx(...ReadOnly: true) starts. |
| transaction.go | Clears inReadOnlyTx on commit/rollback so the exemption doesn’t outlive the transaction. |
| packets.go | Makes read-only error rejection unconditional (gated only by !inReadOnlyTx). |
| dsn.go | Removes Config.RejectReadOnly, drops DSN formatting for it, and changes DSN parsing semantics for rejectReadOnly. |
| driver_test.go | Updates upstream read-only rejection test to reflect “always-on” behavior and no-parameter default. |
| dsn_test.go | Updates DSN parsing expectations now that rejectReadOnly=true is a no-op. |
| readonly_test.go | Adds fork-owned unit tests for DSN parameter behavior, ErrBadConn mapping + connection closure, and the read-only-tx exemption. |
| README.md | Documents the fork behavior change, DSN parameter semantics, and the explicit read-only transaction carve-out. |
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
🤖 Adversarial correctness review — The argument for making this unconditional is right, and the part I'd have pushed back on is already handled: the read-only-transaction exemption is the correct carve-out, for the correct reason ( My findings are about the two silences that are left.
1 — the flag that disarms the protection is never driven by a test (med)Four mutations against the new logic, full suite each time (
The second one is the one that matters. What makes it worth a finding rather than a coverage note is that // Once the transaction ends the exemption must end with it, or one
// read-only transaction disarms the protection for the rest of the
// connection's life.
mc.inReadOnlyTx = falseThe comment describes exactly the mutation that survives, and then the test satisfies it by assigning the field itself. Nothing in the suite ever calls
While that's open: clearing 2 — the original error is dropped on the floor, and nothing logs it (low-med) mc.Close()
return driver.ErrBadConnThe case for this change is that a demoted-writer connection fails "with no error that says the connection is the problem." That's true, and it's fixed for the failover case, because It is not fixed for the case where the target stays read-only — a reader endpoint, a cluster with no writer, So the silence didn't close, it moved: from "a write that mysteriously fails forever" to "a write that mysteriously fails forever and churns three connections per attempt." The README's "the caller sees One line fixes it, using the logger already wired into mc.log("closing read-only connection, errno ", errno, ": ", string(data[3:]))
mc.Close()
return driver.ErrBadConnNow the failover case logs once and moves on, and the persistent case leaves a repeating line that names the condition. That is the whole diagnostic gap, and it costs nothing on the path that matters. 3 — who loses the option, and what 1290 actually means (low)Two things I'd add to the Name the other 1290s. "ERROR 1290 is also raised for some conditions unrelated to read-only mode" is right but not actionable. The ones an operator actually meets are Say what a reader-endpoint deployment should do. Upstream's removed text told you not to enable this if the database is intentionally read-only; that advice no longer has anywhere to go, because Verified — the suite, and three attacks that dissolvedLocal. Attack that dissolved: the exemption being set before the statement that could trip it. Attack that dissolved: the flag surviving into the next borrower of a pooled connection. Every path out of a Attack that dissolved: Leak check: clean. Nothing internal in the diff or the README additions; RDS and Aurora are the vendor's own product names and the failover behaviour described is public. Every mutation was restored from backup; the worktree is clean at This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving — making it unconditional is the right call and the read-only-transaction carve-out is correct for the right reason. Findings in the review comment above: the exemption flag's lifecycle is untested (setting it unconditionally turns the whole feature off with the suite green), and the discarded server error leaves a persistently read-only target with no trace of read-only anywhere — one mc.log closes it.
This stamp was left by Claude Code (claude-opus-5).
|
🤖 All three addressed in 1 — the lifecycle is now driven.
Each subtest ends by feeding a real 1792 packet back through I also took the 2 — logged. 3 — README. Named the four ( The reader-endpoint paragraph is in, and it is worth flagging that this is not hypothetical: Verification: full suite at |
Upstream makes this an option (rejectReadOnly) defaulting to off. It is unconditional here, and Config.RejectReadOnly is gone. The failure it prevents is silent, and the mistake that causes it is invisible. RDS and Aurora fail over by moving DNS: a pooled connection to the demoted writer stays open and stays usable, and every write on it fails for as long as the pool keeps it — potentially until the process restarts. Nothing in the DSN or in the error says the connection is the problem, so a deployment that left the option off does not find out until a failover, which is the worst possible moment to learn it. An option whose only correct setting in the environment we deploy in is "on" is not really an option; it is a step you can forget. What happens to the parameter: rejectReadOnly=true still parses and does nothing, so a DSN written for upstream keeps working. rejectReadOnly=false is an error rather than a silent no-op — it states an expectation the driver will not meet, and quietly ignoring it is the same class of problem this change exists to remove. One carve-out, which upstream's own test suite found: a transaction opened with driver.TxOptions.ReadOnly is exempt. There the read-only error is the answer the caller asked for, and database/sql does not retry inside a transaction, so rejecting would replace a usable *MySQLError with a dead transaction — TestContextBeginReadOnly failed exactly that way before the exemption, and passes unmodified with it. A session the application makes read-only with its own SET is deliberately not exempt: nothing distinguishes it from a demoted writer. Upstream's TestRejectReadOnly loses the case where the option is off, since that is no longer a state the driver can be in; the first case now covers a DSN that says nothing, which is where the old default did the wrong thing. Full suite passes against MySQL 8.0.44, race enabled.
Three fixes from review. The exemption flag had no test driving it. TestReadOnlyTxIsExempt proved handleErrorPacket reads it, but set the field itself, so three mutations survived the full suite: begin setting it unconditionally, and either of Commit or Rollback failing to clear it. The first is the worst — every transaction exempt means any connection that has ever run a BeginTx stops rejecting read-only errors for the rest of its life in the pool, the feature silently off. TestReadOnlyTxLifecycle drives begin/Commit/Rollback against a mock server and kills all three. ResetSession now clears the flag too. Every sql.Tx ends in Commit or Rollback so this should be unreachable, but the stuck direction is the unsafe one and a pooled connection's assumptions belong there. The server's error was discarded with nothing logged. For the failover this is written for that is fine — database/sql retries and the caller sees nothing. For a target that stays read-only it is not: the retry budget burns, the caller gets a bare driver.ErrBadConn, and the one string that named the problem was assembled nowhere. Log it before closing. README: name the 1290s that are not failover (secure_file_priv, super_read_only, innodb_read_only, --skip-grant-tables), and say plainly what a deliberately read-only deployment should do now that the option is gone.
d987085 to
adddf85
Compare
Upstream makes this an option (
rejectReadOnly) defaulting to off. It is unconditional here, andConfig.RejectReadOnlyis gone.Why it shouldn't be an option
RDS and Aurora fail over by moving DNS. A pooled connection to the demoted writer stays open and stays usable, so every write on it fails for as long as the pool keeps it — potentially until the process restarts. Nothing in the DSN or in the error says the connection is the problem, so a deployment that left the option off doesn't find out until a failover, which is the worst moment to learn it.
An option whose only correct setting in the environment we deploy in is "on" isn't really an option; it's a step you can forget. Removing it removes the failure mode.
What happens to the parameter
rejectReadOnly=truerejectReadOnly=falserejectReadOnly=yesConfig.RejectReadOnlyis removed rather than kept-and-ignored, so a caller setting it in Go fails to compile instead of silently having no effect. (strata'spkg/mysqlrdsis the one place in Block code that sets it; the line just deletes.)One carve-out, which upstream's own suite found
A transaction opened with
driver.TxOptions.ReadOnlyis exempt. There the read-only error is the answer the caller asked for, anddatabase/sqldoes not retry inside a transaction — so rejecting replaces a usable*MySQLErrorwith a dead transaction.TestContextBeginReadOnlyfailed exactly that way before the exemption:and passes unmodified with it. The flag is set in
begin()and cleared on commit/rollback.A session the application makes read-only with its own
SET SESSION TRANSACTION READ ONLYis deliberately not exempt — nothing distinguishes it from a demoted writer. That is a real behaviour change for anyone relying on session state to reject writes, and it's documented in the README.Tests
readonly_test.go(new, fork-owned) covers the DSN parameter in all three directions, the three error numbers each yieldingErrBadConnwith the connection closed, an unrelated error (1062) still surfacing as itself with the connection intact, and the read-only-transaction exemption ending when the transaction does — otherwise one read-only transaction would disarm the protection for the rest of the connection's life.Upstream's
TestRejectReadOnlyloses the "option off" case, since that is no longer a state the driver can be in; the first case now covers a DSN that says nothing, which is where the old default did the wrong thing.Full suite passes against MySQL 8.0.44 with
-race.Merge order
Independent of #4, but both touch the same README paragraphs; whichever lands second gets rebased.