Skip to content

[codex] Use writer-consistent reads after MySQL writes#12

Closed
alexstandiford wants to merge 1 commit into
mainfrom
codex/read-after-write-consistency
Closed

[codex] Use writer-consistent reads after MySQL writes#12
alexstandiford wants to merge 1 commit into
mainfrom
codex/read-after-write-consistency

Conversation

@alexstandiford

@alexstandiford alexstandiford commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

What changed

This keeps the read-after-write consistency fix entirely inside the MySQL integration.

The behavior is dynamic:

  • Normal reads still use the normal query path.
  • insert() runs the insert and SELECT LAST_INSERT_ID() inside the same transaction.
  • After insert(), update(), or delete(), the strategy marks that a write occurred.
  • Subsequent reads through that strategy instance are wrapped in a short transaction so they use the writer-consistent path.

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:

  • WooCommerce order 8870 completed at 2026-04-27 16:27:03 UTC.
  • Siren conversions 19 and 20 were created at 2026-04-27 16:26:49 and 2026-04-27 16:26:57 UTC with transactionId = NULL.
  • Siren transaction rows 29 and 30 existed with matching timestamps, proving the inserts committed.
  • Transaction 30 also had a transaction detail row, but neither transaction had a wc_order mapping for order 8870.
  • Earlier live logs showed RecordNotFoundException during IdentifiableDatabaseDatastoreHandler->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:

  • $wpdb class: wpdb
  • wp-content/db.php: absent
  • DB_HOST: local socket (localhost:/run/mysqld/mysqld.sock)

On that standard Cloudways app, the issue did not reproduce:

  • 100 direct insert/select cycles outside a transaction: 0 failures.
  • 100 direct insert/select cycles inside a transaction: 0 failures.
  • 10 replays of the WooCommerce completed-order hook outside a transaction: all succeeded.
  • 10 replays inside a transaction: all succeeded.

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.php
  • Result: 2 tests, 8 assertions passing

Note

The full mysql-integration test suite is still blocked by pre-existing package bootstrap drift: there is no local vendor/bin/phpunit, and tests/Unit/ValidateCITest.php references the old Phoenix\\Core\\Tests\\TestCase namespace.

@alexstandiford alexstandiford force-pushed the codex/read-after-write-consistency branch from de19602 to 47dbae8 Compare April 28, 2026 01:35
@alexstandiford alexstandiford changed the title [codex] Add write-consistent MySQL reads [codex] Resolve MySQL insert identity in transaction Apr 28, 2026
@alexstandiford alexstandiford force-pushed the codex/read-after-write-consistency branch from 47dbae8 to 88de410 Compare April 28, 2026 02:06
@alexstandiford alexstandiford changed the title [codex] Resolve MySQL insert identity in transaction [codex] Use writer-consistent reads after MySQL writes Apr 28, 2026
@alexstandiford alexstandiford force-pushed the codex/read-after-write-consistency branch from 88de410 to 68188ae Compare April 28, 2026 02:20

Copy link
Copy Markdown
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:

  • Browser Stripe checkout completed and Siren created a populated conversion, transaction, obligation, and wc_order mapping.
  • Stripe process-response, immediate webhook, and gateway test API paths all succeeded.
  • A low-level web-pod consistency probe returned 0 failures across 500 raw $wpdb insert/read loops without transactions, 500 with transactions, and 200 PHPNomad datastore creates.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant