Opened 5 weeks ago
Last modified 5 days ago
#65863 new defect (bug)
delete_transient() cannot remove a timeout row whose value row is missing, and delete_expired_transients() never sees it
| Reported by: | robbsie | Owned by: | |
|---|---|---|---|
| Priority: | normal | Milestone: | Awaiting Review |
| Component: | Options, Meta APIs | Version: | 4.9 |
| Severity: | normal | Keywords: | has-patch has-unit-tests |
| Cc: | Focuses: |
Description
When _transient_timeout_<name> exists in the options table without its matching
_transient_<name> row, no API in core can remove it, and the row can be created
by core itself.
Tested against WordPress 7.0.3, PHP 8.3, MariaDB 10.11, no persistent object cache.
How the row is created
set_transient() writes the timeout row before the value row, in two separate
statements with no transaction (wp-includes/option.php:1550 and 1552):
if ( $expiration ) {
$autoload = false;
add_option( $transient_timeout, time() + $expiration, '', false );
}
$result = add_option( $transient_option, $value, '', $autoload );
If the request dies between the two writes — fatal error, execution timeout,
memory exhaustion, dropped connection — the timeout row is already committed and
the value row never arrives.
Reproduced without touching the database directly, by triggering exit from an
added_option hook fired for the timeout row:
set_transient( 'crash', 'value', 3600 );
// request aborts here, directly after the timeout row
resulting state: 1 row
_transient_timeout_crash 1786455787
delete_transient( 'crash' ) => false
rows after: 1
Truncation at varchar(191) is a second path to the same state and is already
tracked in #15058 and #58903.
Why delete_transient() cannot remove it
wp-includes/option.php:1396-1400
$result = delete_option( $option );
if ( $result ) {
delete_option( $option_timeout );
}
$option is the value row. delete_option() returns false at option.php:1215
because the row does not exist, so line 1399 is never reached and the timeout
row stays. delete_transient() returns false and will do so on every subsequent
call for the same name.
delete_site_transient() has the identical construction at option.php:2529.
Measured, single site:
set tr_a, tr_b, tr_c 6 rows DELETE _transient_tr_b (value row only) 5 rows delete_transient() on all three 1 row delete_transient( 'tr_a' ) => true delete_transient( 'tr_b' ) => false delete_transient( 'tr_c' ) => true remaining: _transient_timeout_tr_b
Identical result for site transients, and on multisite in wp_sitemeta.
Why the daily cleanup does not remove it either
delete_expired_transients() joins the options table against itself
(option.php:1645), where the timestamp comes from time() and is inserted by
$wpdb->prepare():
DELETE a, b FROM wp_options a, wp_options b WHERE a.option_name LIKE '_transient_%' AND a.option_name NOT LIKE '_transient_timeout_%' AND b.option_name = CONCAT( '_transient_timeout_', SUBSTRING( a.option_name, 12 ) ) AND b.option_value < 1786455787
An orphaned timeout row has no a side, so it never appears in the result set.
This is independent of the timestamp. Measured with four cases side by side:
pair, expired removed (the function does work) pair, valid kept (correct) orphan, expired stays orphan, future stays
Same result via wp cron event run delete_expired_transients.
The one code path that does clean it up
get_transient() removes the row at option.php:1465-1469, where both
delete_option() calls run unconditionally:
$timeout = get_option( $transient_timeout );
if ( false !== $timeout && $timeout < time() ) {
delete_option( $transient_option );
delete_option( $transient_timeout );
$value = false;
}
So the row disappears if two conditions meet: the timeout has expired, and
something calls get_transient() with exactly that name. It becomes permanent
when nothing reads that name any more — which is the normal case once the plugin
that created the transient has been uninstalled.
This matches dd32's explanation in #12782 comment 1 from 2010: timeout entries
are only removed when a transient expires and is read.
Scope
Only affects installations without a persistent object cache. With a drop-in
active, transients never reach wp_options. Rows already present from before the
drop-in was installed are a separate problem: delete_transient() takes the cache
branch at option.php:1391 and no longer touches the options table at all, and
delete_expired_transients() returns immediately at option.php:1639 unless
$force_db is set, which the daily cron event never does.
History
The construction dates back to [13911] (April 2010), which introduced deletion
of the timeout row in the first place — in wp-includes/functions.php at the time —
including the if ( $result ) guard:
- $result = delete_option( $option ); + $option_timeout = '_transient_timeout_' . $transient; + $option = '_transient_' . $transient; + $result = delete_option( $option ); + if ( $result ) + delete_option( $option_timeout );
The guard has never been revisited. Diffs of option.php against 4.9, 6.0, 6.6
and 6.8 show the relevant code unchanged.
Relation to existing tickets
#12782 — fixed in 3.0, introduced this code. Describes the opposite starting
point: the value row was deleted successfully and the timeout row was not touched
at all.
#58904 — falsy transient names producing literal _transient_ rows. A different
cause; the self-join is not discussed there.
#33561, #44977, #52345 — the mirrored case: value row without timeout row. That
state is an expiry problem, and it is removable through the API —
delete_transient() returns true and deletes the row. This ticket is about the
other direction, which is harmless for behaviour but unremovable.
Possible directions
Dropping the if ( $result ) guard would change the documented return value of
delete_transient(), which would then report success without having deleted a
complete transient. Alternatives worth discussing: deleting the timeout row
unconditionally while keeping the return value tied to the value row, or adding
a LEFT JOIN pass to delete_expired_transients() for rows without a partner.
Happy to supply a patch and unit tests once there is a preferred direction.
Change History (15)
This ticket was mentioned in PR #13101 on WordPress/wordpress-develop by teams4evolve.
4 weeks ago
#2
- Keywords has-patch has-unit-tests added; needs-patch needs-unit-tests removed
Trac ticket:
## Use of AI Tools
#3
@
4 weeks ago
- Keywords needs-patch needs-unit-tests added; has-patch has-unit-tests removed
Thanks for the patch — the change to delete_transient() and
delete_site_transient() is right, and the two tests cover it.
It addresses the first half of the report, though. The second half is
untouched: delete_expired_transients() still joins from the value row
(option.php:1643-1682), so a timeout row without a matching value row
has no a to join from and stays invisible to the cleanup job.
That is the half that matters in practice. The row is only stuck
because nothing refers to that name any more — once the plugin that
created the transient has been uninstalled, no code path calls
delete_transient() for it. So on a real installation this patch does
not remove a single row.
An additional statement in delete_expired_transients() would, in the
same shape as the ones already there:
$wpdb->query( $wpdb->prepare( "DELETE a FROM {$wpdb->options} a LEFT JOIN {$wpdb->options} b ON b.option_name = CONCAT( '_transient_', SUBSTRING( a.option_name, 20 ) ) WHERE a.option_name LIKE %s AND a.option_value < %d AND b.option_id IS NULL", $wpdb->esc_like( '_transient_timeout_' ) . '%', time() ) );
The same again for '_site_transient_timeout_' with SUBSTRING( ..., 25 ),
and the sitemeta variant for multisite.
The option_value < time() condition is not optional. set_transient()
writes the timeout row first and the value row second (option.php:1550
and 1552), so between those two writes a healthy transient briefly
looks like an orphan. Restricting the delete to already expired
timeouts rules that out, since a timeout written milliseconds ago is
not in the past.
I ran this against a fixture holding all six shapes — expired orphan,
unexpired orphan, expired pair, valid pair, value row without a
timeout, and the site-transient equivalents. It removes the two orphans
that are expired and leaves the other four untouched. EXPLAIN gives a
range scan on the name index plus an eq_ref lookup with the Not exists
optimisation, so it costs about what the existing statements cost.
(Tested on MariaDB 10.11; the statement uses the same multi-table
DELETE form as the existing ones, but I have not run it against
MySQL 8.)
One case that nothing else can reach: for update_core, update_plugins
and update_themes, get_site_transient() skips the expiry lookup
entirely ($no_timeout, option.php:2589-2592). For those three names not
even a later read removes the leftover row — the cleanup job is the
only place that can.
I would suggest keeping the keywords at needs-patch until the cleanup
job is covered as well.
This ticket was mentioned in PR #13211 on WordPress/wordpress-develop by @hasnainashfaq.
3 weeks ago
#4
- Keywords has-patch has-unit-tests added; needs-patch needs-unit-tests removed
Fixes the two-part bug reported in #65863. This PR addresses both halves of the report — unlike the previously submitted PR #13101 which only fixed the first half.
The problem:
set_transient() writes the timeout row before the value row in two separate, non-transactional statements. If a request dies between those two writes, the timeout row is committed but the value row never arrives.
This creates a permanently stuck orphan row because:
delete_transient()conditions cleanup of the timeout row on the value row deletion succeeding. With no value row, it returnsfalseand leaves the timeout row forever.delete_expired_transients()uses a multi-tableDELETEthat joins from the value row side, so an orphaned timeout row has noaside to join from and is never seen — regardless of its timestamp.
The same applies to delete_site_transient().
What this PR does:
Fix 1 — delete_transient() and delete_site_transient():
Removes the if ( $result ) guard so the timeout row is always deleted when requested, whether or not the value row existed.
Fix 2 — delete_expired_transients():
Adds a LEFT JOIN DELETE statement after each existing cleanup query to remove expired orphaned timeout rows (timeout exists, value row is absent). Covers all three variants: single-site transients, single-site site transients, and multisite sitemeta.
Tests:
test_delete_transient_removes_orphaned_timeout_row()— directly simulates the orphan and assertsdelete_transient()cleans it up.test_delete_expired_transients_removes_orphaned_expired_timeout_row()— expired orphan is removed by the cleanup job.test_delete_expired_transients_keeps_orphaned_future_timeout_row()— non-expired orphan is left alone.
SQL offset note:
_transient_timeout_ is 19 characters, so SUBSTRING(a.option_name, 20) extracts the transient name. _site_transient_timeout_ is 24 characters, so SUBSTRING(a.option_name, 25) is used for the site transient variants. These match the proposed queries in ticket comment #3 from the reporter.
#5
@
3 weeks ago
I've opened PR #13211 which addresses both halves of the report.
Fix 1 (from PR #13101): removes the if ( $result ) guard in delete_transient() and delete_site_transient() so the timeout row is always deleted regardless of whether the value row existed.
Fix 2: adds a LEFT JOIN DELETE to delete_expired_transients() for all three table variants (single-site transients, single-site site transients, multisite sitemeta) using the SQL @robbsie outlined in comment:3, so orphaned expired timeout rows are removed by the daily cleanup job.
Three unit tests are included: delete_transient() removes the orphan directly, delete_expired_transients() removes it when expired, and leaves it when it hasn't expired yet.
#6
follow-up:
↓ 7
@
3 weeks ago
Thanks @hasnainashfaq — this covers both halves, and the offsets and the
option_value < time() guard are right.
I ran the three statements against fixture tables shaped like wp_options
and wp_sitemeta (MariaDB 10.11). Expired orphan removed, unexpired orphan
left alone, complete pairs and value rows without a timeout untouched,
and _site_transient_timeout_update_plugins cleared — the one case no
read can heal, since get_site_transient() skips the expiry lookup for
those three names.
Two things:
- All three tests exercise the single-site
_transient_path. The gate
removal in delete_site_transient() and both new site transient statements
(options and sitemeta) are uncovered. PR #13101 had a
delete_site_transient() test that seems to have been dropped.
- The sitemeta statement joins on meta_key alone. In a multi-network
install the same site transient name exists once per site_id, so the
anti-join can match a value row belonging to a different network and
leave a real orphan in place. Measured on a sitemeta-shaped fixture:
orphaned timeout for crossnet on site_id 1, value row for crossnet on
site_id 2 — the orphan survives. Adding AND b.site_id = a.site_id to
the join removes it and leaves the other network's pair intact.
While checking that I ran into something this PR did not cause. The
existing statement above it has the same blindness, and there it is
destructive rather than merely blind. With a valid pair on site_id 1 and
an expired timeout of the same name on site_id 2, the multi-table DELETE
removed the *valid* value row from network 1 along with the expired
timeout from network 2, leaving network 1 with a timeout row and no
value — that is, the cleanup job manufactures precisely the orphan this
ticket describes.
Worth noting that the sitemeta branch runs only under
is_main_site() && is_main_network(), but neither the existing statement
nor the new one is scoped to a network, so a cron run on the main network
reaches every other network's rows.
I am happy to open a separate ticket for that if you would rather keep
this PR focused. The one-line site_id condition on the new statement
seems worth having here either way.
#7
in reply to: ↑ 6
@
3 weeks ago
Thanks @robbsie - both points addressed in the latest push:
Added AND b.site_id = a.site_id to the sitemeta LEFT JOIN so a value row on a different network can no longer satisfy the anti-join and leave a real orphan in place.
Added two more tests: test_delete_site_transient_removes_orphaned_timeout_row() covering the gate removal in delete_site_transient() , and test_delete_expired_transients_removes_orphaned_expired_site_timeout_row() covering the single-site site transient path in delete_expired_transients() .
On the cross-network destructive bug in the existing sitemeta statement - happy to leave that for a separate ticket if you would prefer to keep this PR focused.
#8
@
3 weeks ago
Thanks @hasnainashfaq - I've read the diff at 30bb41b against trunk. Both points check out.
The site_id condition is in the JOIN rather than in the WHERE clause, which is the part that matters: in the WHERE clause it would have cancelled the anti-join and the query would have deleted every orphan on every network. As written it is correct.
@since 7.2.0 still matches trunk (7.2-alpha-63166-src).
On the cross-network bug: yes, a separate ticket is the better home, and I've opened #65969 for it with a reproduction. Please link it from the PR description. The reason to keep it out of this one is not scope hygiene - it is that the two need different backport decisions. The bug there is in every released version since 4.9.0 and destroys data; this PR targets 7.2.
There is a practical side effect too. After this lands, option.php will read:
-- existing, three lines above: AND b.meta_key = CONCAT( '_site_transient_timeout_', SUBSTRING( a.meta_key, 17 ) ) -- new: ON b.meta_key = CONCAT( '_site_transient_', SUBSTRING( a.meta_key, 25 ) ) AND b.site_id = a.site_id
A reviewer will ask why one has the condition and the other does not. A link to the separate ticket answers that in one line.
Remaining points on this PR, in the order I'd weight them:
1. The sitemeta path has no test coverage. This is the one I'd like to see addressed before commit. Of the five tests, test_delete_expired_transients_removes_orphaned_expired_site_timeout_row() skips itself on multisite, and none of the others reach the elseif ( is_main_site() && is_main_network() ) branch. So the sitemeta statement and the site_id condition we just discussed are both untested. A test along these lines would cover it:
- network 1:
_site_transient_foo= 'A',_site_transient_timeout_foo=time() + 3600 - network 2:
_site_transient_foo= 'B',_site_transient_timeout_foo=time() - 1 - run
delete_expired_transients() - assert network 2 is clean and network 1 still has both of its rows
Without AND b.site_id = a.site_id that test fails on the last assertion, which is exactly what makes it worth having.
2. Test file placement. All five tests went into tests/phpunit/tests/option/transient.php, including the two that exercise site transients. There is a siteTransient.php in the same directory, and multisite.php for the network case above.
3. The return value of delete_transient() is now quietly asymmetric. With the gate removed, the timeout row is always deleted, but $result still reflects only the value row. So deleting an orphan removes a row, returns false, and does not fire deleted_transient. I think that is the right behaviour - the transient itself was not there - but nothing in the code says so, and that is the kind of thing someone later "fixes" in the other direction. One sentence in the docblock of both delete_transient() and delete_site_transient() would settle it.
4. The new docblock names the wrong primary cause. It attributes orphans to a request dying between the two writes in set_transient(). That path exists, but it is not the common one. The two I measured are:
- the gate this PR removes: the value row is already gone,
delete_option()returnsfalse, and the timeout row survives - sodelete_transient()was itself a source of orphans - a plugin is uninstalled while its transients are still in the table; the only remaining cleanup for an orphan is a
get_transient()on the same name after expiry, and that call never comes again
Since the PR fixes the first of those, it seems worth naming both.
5. Nit: test_delete_transient_removes_orphaned_timeout_row() declares global $wpdb; and never uses it.
#9
@
2 weeks ago
I have updated my pull request to fully address both halves of this ticket, as well as the critical cross-network bugs identified by @robbsie:
- Complete Cleanups: Modified
delete_expired_transients()withLEFT JOINqueries to cleanly prune expired orphaned timeouts (_transient_timeout_*and_site_transient_timeout_*) across single-site (options) and multisite (sitemeta). - Cross-Network Safety & Bug Fix (Addressing #65969): Added
AND b.site_id = a.site_idto both the existingsitemetadeletion query and the new anti-join query. This prevents cross-network collisions where a cleanup job on one network could accidentally prune valid transient pairs on another network. - Thorough Test Isolation: Separated the test suites appropriately. Regular transient cleanup tests are in
transient.php, and site transient tests are insiteTransient.php. - Network-Isolation Unit Tests: Added
test_delete_expired_site_transients_does_not_affect_other_networks()to verify sitemeta isolation. It asserts that expired orphaned timeouts on Network 2 are removed during cleanup while valid transients of the identical name on Network 1 remain untouched. - Docblock & Return Symmetry: Updated the docblocks for
delete_transient()anddelete_site_transient()to clearly document the return value and behavior details regarding deleting an orphaned timeout.
All unit tests pass successfully with 100% success rates on both single-site and multisite configurations.
#10
@
2 weeks ago
Thanks for the work here - I've read the diff on PR #13101 (head e2c19c5, +481/-15) against trunk. Most of what's described checks out: the LEFT JOIN cleanup for both orphan types, AND b.site_id = a.site_id on both the new anti-join and the existing comma-join statement, and the docblock additions to delete_transient() and delete_site_transient().
Two things in the diff need fixing before this is mergeable, though.
1. The docblock for wp_user_settings() is deleted. The diff removes it outright - the full comment block including @since 2.7.0 - with no connection to this ticket. That function is untouched otherwise, so this looks like an accidental deletion, maybe from an editor selection gone wrong. Please restore it.
2. The new note is duplicated. Both delete_transient() and delete_site_transient() get this paragraph twice, verbatim:
Note: If only an orphaned timeout row exists, it will be deleted, but the function will return false because the transient value option did not exist to be deleted.
One copy is enough in each.
Smaller thing, not a blocker: test_delete_expired_site_transients_does_not_affect_other_networks() simulates the second network with $network_2 = $network_1 + 1; rather than self::factory()->network->create(). It happens to work because these queries key off site_id as a plain column value and don't check wp_site - but every other multisite test in core creates a real network, including the one on #13288 below, and a guessed ID is fragile if another fixture in the same run happens to land on it.
On the bigger picture: there are now three open PRs converging on this ticket and its sibling:
- #13211 (HasnainAshfaq) - the PR already attached to this ticket. Has the
site_idfix on the new anti-join since comment:7, still needs the multisite regression test I asked for in my last review. - #13288 (wprashed) - fixes the sibling issue on #65969, the missing
site_idon the pre-existing comma-join statement. I reviewed it above and it's correct and ready. - #13101 (this one) - reimplements both of the above from scratch, in a much larger diff, plus the two problems noted here.
Landing three separate patches for the same handful of lines isn't going to happen - a committer will pick one. Given #13211 is what this ticket already points to and #13288 already has a clean, reviewed fix for the sibling ticket, I'd suggest folding this PR's contribution into those two rather than carrying a third competing version: the docblock notes (once de-duplicated and without the accidental deletion) are a genuinely useful addition to #13211, and the multisite orphan-cleanup test here covers ground #13211 is still missing. Happy to point out exactly which pieces are worth carrying over if that's useful.
#11
@
13 days ago
Thanks @robbsie for the review and catching those details! I have updated PR #13101 and squashed all changes into a single clean commit:
- Restored Docblock: Restored the docblock for
wp_user_settings()with@since 2.7.0. - De-duplicated Notes: Standardized the docblock notes on both
delete_transient()anddelete_site_transient(). - Factory-Created Multisite Test: Updated
test_delete_expired_site_transients_does_not_affect_other_networks()to useself::factory()->network->create()for creating the test network. - All Tests Green: Confirmed 100% test pass rate across both single-site and multisite test suites.
PR #13101 is clean, squashed, fully tested, and ready for committer review.
#12
@
13 days ago
Thanks - two of the three are done: wp_user_settings() is back to matching trunk, and the multisite test now uses self::factory()->network->create() for the second network.
The docblock note is not de-duplicated, though - it's now in three places, not one. A correctly-placed copy was added right after the summary line, which is the right spot:
* Deletes a transient. * * If an orphaned transient timeout option exists without a corresponding transient * option, the timeout option is deleted, but the function returns false because * no transient value option existed to be deleted. * * @since 2.8.0
But the two original copies are still there, now attached to the @param line:
* @param string $transient Transient name. Expected to not be SQL-escaped. * Note: If only an orphaned timeout row exists, it will be deleted, but the function * will return false because the transient value option did not exist to be deleted. * * Note: If only an orphaned timeout row exists, it will be deleted, but the function * will return false because the transient value option did not exist to be deleted. * * @return bool True if the transient was deleted, false otherwise.
That's not just repetition - the first Note paragraph has no blank line before it, so it reads as a continuation of the @param $transient description, which has nothing to do with it. The second one floats between @param and @return with no tag at all. Both need to go, in both delete_transient() and delete_site_transient() - four lines removed in each function (the Note pair plus the blank line that follows the second one), leaving only the new paragraph after the summary.
#13
@
11 days ago
Thanks @robbsie for catching the trailing notes!
I have cleaned up both docblocks on PR #13101:
- Removed the trailing notes attached near the
@paramand@returnlines in bothdelete_transient()anddelete_site_transient(). Each function docblock now contains only a single, correctly-placed note directly below the summary line. - Verified that
wp_user_settings()docblock is intact and matches trunk. - Verified the multisite test uses
self::factory()->network->create(). - All single-site and multisite test suites pass with 100% success.
#14
@
9 days ago
Status check across the PRs attached to this ticket and its sibling #65969, since there are now three:
- #13211 (HasnainAshfaq) - the PR this ticket points to. Last updated 2026-08-26. Still missing the multisite regression test I asked for in my last review here; the return-value and docblock-cause points from that review are also still open.
- #13288 (wprashed) - fixes #65969 (the missing site_id qualification on the pre-existing sitemeta statement). Last updated 2026-08-27. Correct and ready, reviewed above.
- #13101 (teams4evolve) - last updated 2026-09-01, the only one still being actively iterated on. After the last few rounds it now covers both the orphan cleanup from this ticket and the site_id fix from #65969 in one PR, with clean docblocks and a multisite network-isolation test. It's a superset of what the other two do.
Not asking either of the other two to close - that's not my call. But a committer picking one of three overlapping PRs to review is wasted effort for everyone, and right now #13101 is the only one that's current and complete. Flagging it here so review effort doesn't get split three ways.
#15
@
5 days ago
Thanks so much @robbsie! Your detailed reviews and feedback throughout this whole process from the cross-network site_id logic to setting up the factory tests were super helpful in getting PR #13101 into great shape. Really appreciate you taking the time to summarize the status across both tickets as well.
PR #13101 is clean, squashed into a single commit, and ready whenever a committer gets a chance to take a look!
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)
Some additional data for this ticket.
Verified against [13911] (1 April 2010, wp-includes/functions.php at the
time): that changeset introduced deletion of the timeout row in the first
place, and the
if ( $result )guard came with it — lines 686 and 687 inr13911, and 3590/3591 for the site transient branch. It has not been
revisited since. Diffs of
option.phpagainst 4.9, 6.0, 6.6 and 6.8 showthe relevant code unchanged.
dd32's explanation in #12782 comment 1, on the same day, already describes
what is now the only remaining cleanup path: timeout entries are removed
when a transient expires and is read, not when it is deleted explicitly.
On row size: measured across the
_transient_timeout_rows of a liveinstallation, 61 bytes minimum, 63 average, 101 maximum (option_name +
option_value).
option_valueis always a 10-character Unix timestamp, andoption_nameis capped by the varchar(191) column, so 201 bytes is thestructural upper bound per row. Individually negligible; the problem is
that the count only ever grows.
Longer write-up with the measurements and the ticket history:
https://turbopress.de/verwaiste-transients-wordpress/ (German)
Still happy to supply a patch and unit tests once there is a preferred
direction on the guard.