Skip to content

Options, Meta APIs: Do not store a transient value without its timeout. - #14340

Open
dhrupo wants to merge 1 commit into
WordPress:trunkfrom
dhrupo:52345-transient-timeout-failure
Open

dhrupo wants to merge 1 commit into
WordPress:trunkfrom
dhrupo:52345-transient-timeout-failure

Conversation

@dhrupo

@dhrupo dhrupo commented Oct 16, 2026

Copy link
Copy Markdown

Trac ticket: https://core.trac.wordpress.org/ticket/52345

Refreshes the approach from #910 by @menno-ll (now conflicting with trunk), following @peterwilsoncc's suggestion in comment:5 on the ticket.

Summary

When set_transient() creates a transient with an expiration, it writes two rows: _transient_timeout_{name} and _transient_{name}. On the "add" paths the result of writing the timeout row was ignored, so if the database rejected that INSERT the value was still stored. With no timeout row, get_transient() returns the value forever. set_site_transient() has the same pattern. sukhendu2002 confirmed the behaviour on trunk in #44977 (comment:5).

Fix

On the add paths, the value is now only stored when the timeout row was written or already exists:

$result = add_option( $transient_timeout, time() + $expiration, '', false )
	|| '' === $wpdb->last_error
	|| false !== get_option( $transient_timeout );

add_option() returning false is not enough on its own to mean "failed":

  • Concurrent requests. It uses INSERT … ON DUPLICATE KEY UPDATE. If another request wrote the same timeout in the same second, MySQL reports 0 changed rows and add_option() returns false. The cache primed at the start of set_transient() still says the row is missing, so only the database error tells a real failure apart. wpdb::query() resets last_error before every query.
  • Orphaned timeout rows, for example when only the value row was deleted. add_option() returns false without querying, and these keep today's behaviour through the get_option() check.

The same rule applies to the branch that adds an expiration to an existing transient without a timeout, and to set_site_transient().

The update paths, where a timeout row already exists, are unchanged. A failed update there still leaves an expiry in place. It also avoids a trap in #910: update_option() returns false when the value is unchanged, so treating that as a failure drops a second set_transient() with the same expiry in the same second.

Testing

New tests (@ticket 52345) in Tests_Option_Transient and Tests_Option_SiteTransient. The database failure is simulated by pointing only the timeout INSERT at a missing table, so wpdb reports a real error, and a concurrent write by replaying the same INSERT from the query filter.

Case trunk this PR
Timeout INSERT fails, new transient (and site transient) value stored with no timeout ❌ false, nothing stored
Timeout INSERT fails while adding an expiry to an existing transient value stored with no timeout ❌ false
Same transient set twice in a second with the same expiry latest value ✅ latest value ✅
Orphaned timeout row stored ✅ stored ✅
Timeout written concurrently by another request (both branches; site transient) stored ✅ stored ✅

The 3 failure cases fail on trunk; all 9 pass with this change, on single site and multisite:

./vendor/bin/phpunit --group 52345                                   OK (9 tests, 24 assertions)
./vendor/bin/phpunit -c tests/phpunit/multisite.xml --group 52345    OK (9 tests, 24 assertions)
./vendor/bin/phpunit --group option                                  Tests: 451, Assertions: 1118, Warnings: 1, Skipped: 3
./vendor/bin/phpunit -c tests/phpunit/multisite.xml --group option   Tests: 486, Assertions: 1232, Warnings: 1, Skipped: 3

The one warning is an existing PHPUnit 10 deprecation notice in Tests_Option_wpUserSettings.

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5.5
Used for: Tracing the transient write paths, writing the fix and tests, and reviewing it for regressions (two review passes found the concurrent-request case, which is now covered by tests). All changes were reviewed by me.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

When `set_transient()` or `set_site_transient()` creates a transient with an expiration and the database rejects the write of its timeout row, the value was still stored. Without a timeout row, `get_transient()` treats it as a transient that never expires.

The value is now only stored when the timeout row was written or already exists. `add_option()` also returns false when the row is already there, for example when a concurrent request wrote the same timeout first, so a missing write is only treated as a failure when the database reports an error. Updating an existing timeout row is unchanged.

Props mennoll, peterwilsoncc, sukhendu2002.
Fixes #52345. See #44977.
@github-actions

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props dhrupo.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

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