Skip to content

fix: invalidate the object cache when bulk-deleting plugin options - #1685

Merged
jakejackson1 merged 1 commit into
hot-patch-6.16.0from
fix/uninstall-option-cache-invalidation
Jul 24, 2026
Merged

fix: invalidate the object cache when bulk-deleting plugin options#1685
jakejackson1 merged 1 commit into
hot-patch-6.16.0from
fix/uninstall-option-cache-invalidation

Conversation

@jakejackson1

Copy link
Copy Markdown
Member

Follow-up to #1684.

The problem

Three cleanup routines removed options with a raw DELETE ... LIKE query. That takes the rows out of the database but leaves whatever WordPress cached behind — the individually cached entry in the options / site-options group, and for an autoloaded row a copy inside the alloptions blob that keeps being loaded on every request.

pattern
Model_Uninstall::remove_plugin_options() gpdf_sl_%
Model_Uninstall::remove_plugin_network_options() gpdf_sl_net_%
Controller_Upgrade_Routines::remove_legacy_update_cache() edd_sl_%

This is observable without a persistent object cache — get_option() / get_site_option() read through the per-request cache too.

Why the upgrade routine is the one that bites

The uninstaller calls deactivate_plugins() immediately afterwards, so little gets a chance to read the stale values. The upgrade routine doesn't — the plugin keeps running, and leaving a legacy autoloaded option in the cache is the exact cost the routine exists to remove.

It was masked by the two delete_option() calls that follow it: core's delete_option() runs wp_load_alloptions( true ), forcing a rebuild from the database that drops the swept rows along the way. But it bails at if ( is_null( $row ) ) return false; before touching the cache when the option doesn't exist — and on a site that never hit an API failure, neither of those two options exists. So the mitigation was real but accidental and conditional.

The fix

Each query now selects the names and deletes through the API.

Two details worth review attention:

  • The network sweep uses delete_network_option() with the row's own site_id, not delete_site_option(). sitemeta is shared across networks and the original query had no site_id filter, so it swept all of them; delete_site_option() would silently narrow the cleanup to the current network and strand the rest.
  • Wildcards are now escaped with esc_like(). 'gpdf_sl_%' previously meant "gpdf, any char, sl, any char, anything" — harmless in practice, but wrong.

Testing

Both uninstaller tests previously flushed the cache by hand before asserting, which papered over the bug:

/* The direct SQL delete bypasses the object cache */
wp_cache_flush();

Those flushes are gone, so the assertions now cover the real behaviour. The upgrade routine had no coverage at all and gains a test, including an unrelated edd_sl_* option that must survive the value filter.

All three tests fail without their corresponding fix (verified by stashing the src/ changes). Full suite green both modes: 1150 tests / 3581 assertions single-site, 1150 / 3680 multisite. phpcs clean.

No changelog entry, by request.

🤖 Generated with Claude Code

@jakejackson1
jakejackson1 force-pushed the fix/uninstall-option-cache-invalidation branch from 42c6bd4 to 08b1297 Compare July 24, 2026 02:51
Three cleanup routines removed options with a raw `DELETE ... LIKE` query, which
takes the rows out of the database but leaves whatever WordPress cached behind:
the individually cached entry in the `options`/`site-options` group, and — for an
autoloaded row — a copy inside the `alloptions` blob that keeps being loaded on
every request.

- Model_Uninstall::remove_plugin_options()          gpdf_sl_%
- Model_Uninstall::remove_plugin_network_options()  gpdf_sl_net_%
- Controller_Upgrade_Routines::remove_legacy_update_cache()  edd_sl_%

The upgrade routine is the one that bites: the plugin keeps running afterwards,
and leaving a legacy autoloaded option in the cache is the exact cost the routine
exists to remove. It was masked by the two delete_option() calls that follow it —
those force a wp_load_alloptions( true ) rebuild — but only when one of those two
options actually exists, which on a site that never hit an API failure it doesn't.

Each query now selects the names and deletes through the API. The network sweep
uses delete_network_option() with the row's own site_id rather than
delete_site_option(), which would silently narrow the cleanup to the current
network and strand rows belonging to the others sharing the sitemeta table.
Wildcards are escaped with esc_like(), so `gpdf_sl_` no longer also matches
`gpdfXslY`.

Both uninstaller tests previously flushed the cache by hand before asserting,
which papered over the bug; the flushes are gone and the assertions now cover it.
The upgrade routine had no coverage and gains a test. All three fail without the
corresponding fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jakejackson1
jakejackson1 force-pushed the fix/uninstall-option-cache-invalidation branch from 08b1297 to 4959171 Compare July 24, 2026 02:52
@jakejackson1
jakejackson1 merged commit b539b60 into hot-patch-6.16.0 Jul 24, 2026
13 of 14 checks passed
@jakejackson1
jakejackson1 deleted the fix/uninstall-option-cache-invalidation branch July 24, 2026 03:13
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