[codex] Use writer-consistent reads after MySQL writes#12
Closed
alexstandiford wants to merge 1 commit into
Closed
[codex] Use writer-consistent reads after MySQL writes#12alexstandiford wants to merge 1 commit into
alexstandiford wants to merge 1 commit into
Conversation
de19602 to
47dbae8
Compare
47dbae8 to
88de410
Compare
88de410 to
68188ae
Compare
Contributor
Author
|
Withdrawing this PR after further reproduction work. We provisioned a Cloudways Autonomous trial app, imported Darren's database/files/plugins/theme, matched PHP 8.2.9, preserved the Cloudways Redis/Object Cache Pro layer, and ran the Stripe/WooCommerce paths again. The original failure did not reproduce:
Given that evidence, this writer-consistent read change is no longer justified for this incident. Closing rather than merging. |
alexstandiford
added a commit
to phpnomad/db
that referenced
this pull request
Apr 30, 2026
…#25) The post-insert read in WithDatastoreHandlerMethods::create() races read-replicas on hosts with write/read-split routers (ProxySQL, MaxScale, RDS Proxy, Aurora, Cloudways Autonomous, etc.). The router routes the autocommit INSERT to master and the immediately-following autocommit SELECT to a replica that hasn't yet received the write, producing RecordNotFoundException after a successful insert. Reproduced live on a customer environment: 14/2200 sequential creates failed (~0.6%), all on the same client connection id, all with LAST_INSERT_ID() returning the right id, all recovering within 10-50ms. Wrapping in an explicit transaction eliminated the failure entirely (because routers pin transactional clients to one backend), but adding transactions to every create has its own trade-offs - and the underlying problem shows up on any backend with eventual consistency (Postgres+pgpool, MongoDB read preferences, Cassandra, search indexes, REST APIs behind CDNs). The cleanest fix is to remove the post-insert read entirely. We already have the data the caller passed in, plus the new identity from the insert response. Construct the model from those in PHP and skip the round-trip. What changed: - Column gains an optional withPhpDefault(callable) setter. The callable is invoked at create time for any column the caller didn't provide, so the in-memory model carries the same value the DB would have defaulted in. Backward compatible: existing columns without a phpDefault behave identically. - DateCreatedFactory and DateModifiedFactory now provide phpDefaults that return the current MySQL-format timestamp. The DB-side DEFAULT CURRENT_TIMESTAMP stays in place as belt-and-suspenders coverage for inserts that bypass the framework. - WithDatastoreHandlerMethods::create() applies phpDefaults to the attributes array, runs the insert, and hydrates the model via modelAdapter->toModel( array_merge(attributes, ids)). No second SELECT. Cache pre-warm via the existing cacheItems() so subsequent reads short-circuit through the cache the same way they do today. The new contract: any column whose value should appear in the post-create() model must either be passed by the caller or come from a column factory that provides a phpDefault. Trigger-set values, generated columns, and DB defaults without a matching phpDefault won't be reflected in the in-memory model - they'll need an explicit refresh by the caller if they're load-bearing. Tests cover: - Column.withPhpDefault / getPhpDefault contract - DateCreatedFactory and DateModifiedFactory produce a working phpDefault - create() hydrates without calling query() (no readback) - create() applies phpDefaults for missing columns - Caller-provided values win over phpDefaults Closes the underlying race that motivated phpnomad/wordpress-integration#30 and phpnomad/mysql-db-integration#12 - both can stay closed.
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.
What changed
This keeps the read-after-write consistency fix entirely inside the MySQL integration.
The behavior is dynamic:
insert()runs the insert andSELECT LAST_INSERT_ID()inside the same transaction.insert(),update(), ordelete(), the strategy marks that a write occurred.No datastore-layer interface was added. No public API changed.
Why
PHPNomad's datastore create flow inserts a row, resolves its identity, and then reads the model back. On managed MySQL hosts with read/write splitting, a read immediately after a write can hit a stale reader.
This fix keeps normal reads cheap while ensuring reads that happen after a write can see the write.
Production Evidence Behind This Fix
The triggering incident was observed on the WordPress integration, but the failure mode is MySQL-level: a write commits, then PHPNomad immediately reads the created row to hydrate the model.
Darren's live SmartWorkflowGuru database was pulled into a disposable test install with WP Migrate DB Pro on April 28, 2026. The failed WooCommerce/Stripe order showed:
8870completed at2026-04-27 16:27:03UTC.19and20were created at2026-04-27 16:26:49and2026-04-27 16:26:57UTC withtransactionId = NULL.29and30existed with matching timestamps, proving the inserts committed.30also had a transaction detail row, but neither transaction had awc_ordermapping for order8870.RecordNotFoundExceptionduringIdentifiableDatabaseDatastoreHandler->create()immediately after inserts.That shape is exactly what happens when PHPNomad's insert succeeds but the immediate post-write read path cannot see the row. The MySQL integration has the same insert-then-read datastore contract, so it gets the same writer-consistent treatment.
Cloudways Test Notes
A standard Cloudways test app was loaded with the live DB. It used plain local-socket MySQL, not the customer's Cloudways Autonomous topology:
$wpdbclass:wpdbwp-content/db.php: absentDB_HOST: local socket (localhost:/run/mysqld/mysqld.sock)On that standard Cloudways app, the issue did not reproduce:
This confirms the generic Cloudways app is not equivalent to the customer's Autonomous database topology. The production DB artifact still supports the read-after-write consistency fix.
Impact
This should prevent a successful insert from being followed by a stale read that makes PHPNomad behave as if the inserted row does not exist.
The extra transaction work only happens on insert identity resolution and on reads after a write has occurred in the same query strategy instance.
Validation
/home/alex/projects/phpnomad/db/vendor/bin/phpunit --testdox --bootstrap tests/bootstrap.php tests/Unit/Strategies/QueryStrategyTest.phpNote
The full mysql-integration test suite is still blocked by pre-existing package bootstrap drift: there is no local
vendor/bin/phpunit, andtests/Unit/ValidateCITest.phpreferences the oldPhoenix\\Core\\Tests\\TestCasenamespace.