Skip to content

Set the MySQL FOUND_ROWS client flag on Cloud SQL connections - #4082

Merged
anth-volk merged 2 commits into
masterfrom
mysql-found-rows
Oct 27, 2026
Merged

anth-volk merged 2 commits into
masterfrom
mysql-found-rows

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #4081
Fixes #4068
Fixes #3946

Summary

Cause. The Public API builds its v1 engine in policyengine_api/data/orm.py with create_engine("mysql+pymysql://", creator=get_connection, ...), where get_connection opens each connection with the Cloud SQL Python Connector. When given a creator, SQLAlchemy does not apply the connection arguments its MySQL dialect would otherwise pass to pymysql, so the CLIENT.FOUND_ROWS client 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 raises StaleDataError ("UPDATE statement on table 'report_outputs' expected to update 1 row(s); 0 were matched."), even though the row was selected FOR UPDATE in the same transaction.

Fix. get_connection now passes client_flag=MYSQL_CLIENT_FOUND_ROWS_FLAG to connector.connect(...). cloud-sql-python-connector 1.20.2 forwards extra keyword arguments to pymysql.Connection, so these connections now get the same flag SQLAlchemy sets on connections it opens from a URL.

orm.py defines the flag's value (2) itself rather than importing pymysql.constants.CLIENT, because rule api.data.sqlalchemy-and-alembic-only doesn't allow application code to import database drivers. The unit tests check the value against pymysql.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 example 17305389924092.037 is stored as 17305389924092.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 every create_engine / engine_from_config call. _build_remote_engine is the only place that opens MySQL connections through a creator. Every other MySQL engine (scripts/v1_database_migration.py, scripts/process_v1_household_mirror_event.py, migrations/v1/env.py and the integration tests) is built from a mysql+pymysql URL, and SQLAlchemy already sets the flag on those, so they are unchanged.

Docs and changelog. One sentence in docs/engineering/skills/alembic-migrations.md explains why the flag is set, and changelog.d/mysql-found-rows.fixed.md records 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 existing ALEMBIC_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 the connect call to pass client_flag with CLIENT.FOUND_ROWS. The existing exact-arguments test now expects client_flag too.
  • tests/integration/test_mysql_update_row_counts.py, run by a new step in the cross-database integration job:
    • Fixed engine: runs the engine _build_remote_engine builds 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 a report_outputs row FOR UPDATE, sets its status with a Core SQL UPDATE, then has the ORM set the same status and flush.
    • Control: runs the same steps on a connection without the flag and expects StaleDataError, so the test can't pass without exercising the failure.
  • Before the fix, against a local MySQL 8.4 container: both unit tests failed because connect received no client_flag, and the real-MySQL test raised StaleDataError: UPDATE statement on table 'report_outputs' expected to update 1 row(s); 0 were matched. After the fix, all of them pass.
  • The suites make test runs (tests/to_refactor, tests/unit, tests/contract, tests/integration/test_budget_window_in_flight_dedupe.py): 3,704 passed.
  • Also passing: ruff format and lint; the quality checks (scripts/run_quality_guards.py, including file length); the rules registry check against master; the rules tool tests; regenerated rules docs, which are unchanged; uv lock --check; and mypy. The noqa count stays at 94 and the type-ignore count at 9.

🤖 Generated with Claude Code

anth-volk and others added 2 commits October 27, 2026 22:03
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

codecov Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.83%. Comparing base (c08d26b) to head (568dd94).
⚠️ Report is 6 commits behind head on master.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@anth-volk
anth-volk marked this pull request as ready for review October 27, 2026 20:16
@anth-volk
anth-volk merged commit ad26e90 into master Oct 27, 2026
15 checks passed

This branch was previously deployed

1 inactive deployment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant