Repository navigation
Set the MySQL FOUND_ROWS client flag on Cloud SQL connections - #4082
Merged
Merged
Conversation
The v1 engine opens its MySQL connections through the Cloud SQL connector, passed to create_engine as creator=. SQLAlchemy then never applies the connection arguments its MySQL dialect would otherwise give pymysql, so the CLIENT.FOUND_ROWS flag is missing. Without it MySQL reports the rows an UPDATE changed rather than the rows it matched, and SQLAlchemy raises StaleDataError when the ORM writes values the row already holds. - Unit tests build the remote engine with a fake connector and require the connect call to pass client_flag with CLIENT.FOUND_ROWS. - A real MySQL test runs the engine the Public API builds against the disposable MySQL database and has the ORM write a status that a SQL UPDATE already set in the same transaction. A control test shows the same steps raise StaleDataError on a connection without the flag. - The cross-database integration workflow runs the new MySQL test file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The v1 engine passes the Cloud SQL connector to create_engine as creator=, so SQLAlchemy never adds the CLIENT.FOUND_ROWS flag its MySQL dialect sets on connections it opens from a URL. Without the flag MySQL reports the rows an UPDATE changed rather than the rows it matched, so an ORM UPDATE that writes values the row already holds reports 0 rows and SQLAlchemy raises StaleDataError. Report saves (PATCH /<country_id>/report) failed this way with a 500. The connector call now passes client_flag with the FOUND_ROWS bit, which the connector forwards to pymysql. The flag's value is written out in orm.py because application code may not import database drivers; the unit tests check it against pymysql.constants.CLIENT.FOUND_ROWS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #4082 +/- ##
=======================================
Coverage 87.83% 87.83%
=======================================
Files 270 270
Lines 16057 16058 +1
Branches 2626 2626
=======================================
+ Hits 14104 14105 +1
Misses 1284 1284
Partials 669 669 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
anth-volk
marked this pull request as ready for review
October 27, 2026 20:16
This branch was previously deployed
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 #4081
Fixes #4068
Fixes #3946
Summary
Cause. The Public API builds its v1 engine in
policyengine_api/data/orm.pywithcreate_engine("mysql+pymysql://", creator=get_connection, ...), whereget_connectionopens each connection with the Cloud SQL Python Connector. When given acreator, SQLAlchemy does not apply the connection arguments its MySQL dialect would otherwise pass to pymysql, so theCLIENT.FOUND_ROWSclient flag was never set. Without it, MySQL reports the rows an UPDATE changed rather than the rows it matched. SQLAlchemy checks each ORM UPDATE against a matched-row count, so an UPDATE that writes values the row already holds reports 0 rows and SQLAlchemy raisesStaleDataError("UPDATE statement on table 'report_outputs' expected to update 1 row(s); 0 were matched."), even though the row was selectedFOR UPDATEin the same transaction.Fix.
get_connectionnow passesclient_flag=MYSQL_CLIENT_FOUND_ROWS_FLAGtoconnector.connect(...).cloud-sql-python-connector1.20.2 forwards extra keyword arguments topymysql.Connection, so these connections now get the same flag SQLAlchemy sets on connections it opens from a URL.orm.pydefines the flag's value (2) itself rather than importingpymysql.constants.CLIENT, because ruleapi.data.sqlalchemy-and-alembic-onlydoesn't allow application code to import database drivers. The unit tests check the value againstpymysql.constants.CLIENT.FOUND_ROWS.Expected effect. This should stop the report-save 500s (
PATCH /<country_id>/report) reported in #3946 and #4068: the UPDATE now counts as matched and the save succeeds, writing the same values. Why the ORM writes values the row already holds is now known: MySQL 8.0's JSON parser stores roughly one in ten long decimals as the neighbouring double (for example17305389924092.037is stored as17305389924092.035), so when the app saves the same output again, SQLAlchemy sees a changed value while MySQL sees none. Production reached those repeat saves mainly through the outdated-report path that #4044 fixed; this PR covers the rest, such as two saves of the same result.Other MySQL connection sites. I searched the repository for
connector.connect(,creator=,pymysql.connect(and everycreate_engine/engine_from_configcall._build_remote_engineis the only place that opens MySQL connections through acreator. Every other MySQL engine (scripts/v1_database_migration.py,scripts/process_v1_household_mirror_event.py,migrations/v1/env.pyand the integration tests) is built from amysql+pymysqlURL, and SQLAlchemy already sets the flag on those, so they are unchanged.Docs and changelog. One sentence in
docs/engineering/skills/alembic-migrations.mdexplains why the flag is set, andchangelog.d/mysql-found-rows.fixed.mdrecords the fix.No environment variable, secret or deploy setting changes. The only workflow change is a new step in the existing MySQL integration job (
.github/workflows/v2-integration-check.yml) that runs the new test file. That step uses the job's existingALEMBIC_DATABASE_URL, which points at the job's own disposable MySQL service container.Testing
The branch has two commits: the first adds failing tests, and the second adds the fix that makes them pass.
tests/unit/data/test_sqlalchemy_v2.py: builds the remote engine with a fake connector and requires theconnectcall to passclient_flagwithCLIENT.FOUND_ROWS. The existing exact-arguments test now expectsclient_flagtoo.tests/integration/test_mysql_update_row_counts.py, run by a new step in the cross-database integration job:_build_remote_enginebuilds against the disposable MySQL database, using a stand-in connector that forwards arguments to pymysql as the real one does. Inside one transaction, it selects areport_outputsrowFOR UPDATE, sets its status with a Core SQL UPDATE, then has the ORM set the same status and flush.StaleDataError, so the test can't pass without exercising the failure.connectreceived noclient_flag, and the real-MySQL test raisedStaleDataError: UPDATE statement on table 'report_outputs' expected to update 1 row(s); 0 were matched.After the fix, all of them pass.make testruns (tests/to_refactor,tests/unit,tests/contract,tests/integration/test_budget_window_in_flight_dedupe.py): 3,704 passed.scripts/run_quality_guards.py, including file length); the rules registry check againstmaster; the rules tool tests; regenerated rules docs, which are unchanged;uv lock --check; and mypy. Thenoqacount stays at 94 and the type-ignore count at 9.🤖 Generated with Claude Code