From c2b109dc20b6cd9a813dd386658db561d1a7821b Mon Sep 17 00:00:00 2001 From: HasnainAshfaq Date: Fri, 21 Aug 2026 02:58:52 +0500 Subject: [PATCH 1/2] Options, Meta APIs: Remove orphaned transient timeout rows on delete and cleanup. delete_transient() and delete_site_transient() conditioned removal of the _transient_timeout_ row on the value row deletion succeeding. When the value row is absent (e.g. a request died between set_transient()'s two separate writes), the timeout row became permanently stuck: every subsequent delete_transient() call returned false and left the row in place. delete_expired_transients() used a multi-table DELETE that joins from the value row side, so an orphaned timeout row with no matching value row was invisible to the cleanup job regardless of its timestamp. This commit addresses both: 1. Remove the if ( $result ) guard in delete_transient() and delete_site_transient() so the timeout row is always deleted when explicitly requested, whether or not the value row existed. 2. Add a LEFT JOIN DELETE statement in delete_expired_transients() for each of the three table variants (single-site transients, single-site site transients, and multisite sitemeta) that targets expired timeout rows whose value row is absent. Includes three unit tests: delete_transient() clears the orphaned row, delete_expired_transients() removes it when expired, and leaves it when it has not yet expired. Fixes https://core.trac.wordpress.org/ticket/65863 --- src/wp-includes/option.php | 56 ++++++++++++++-- tests/phpunit/tests/option/transient.php | 82 ++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 8bd6a1821162e..9781ee0aa5384 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -1397,9 +1397,7 @@ function delete_transient( $transient ) { $option = '_transient_' . $transient; $result = delete_option( $option ); - if ( $result ) { - delete_option( $option_timeout ); - } + delete_option( $option_timeout ); } if ( $result ) { @@ -1629,7 +1627,13 @@ function set_transient( $transient, $value, $expiration = 0 ) { * The multi-table delete syntax is used to delete the transient record * from table a, and the corresponding transient_timeout record from table b. * + * Orphaned timeout rows — where a `_transient_timeout_` row exists without a + * matching `_transient_` row — are also removed when their timestamp has passed. + * These can be created when a request dies between the two writes in + * set_transient(), leaving the timeout row committed but the value row absent. + * * @since 4.9.0 + * @since 7.2.0 Orphaned timeout rows without a matching value row are also deleted. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -1655,6 +1659,20 @@ function delete_expired_transients( $force_db = false ) { ) ); + // Remove orphaned transient timeout rows (timeout exists, value row is missing). + $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() + ) + ); + if ( ! is_multisite() ) { // Single site stores site transients in the options table. $wpdb->query( @@ -1669,6 +1687,20 @@ function delete_expired_transients( $force_db = false ) { time() ) ); + + // Remove orphaned site transient timeout rows (timeout exists, value row is missing). + $wpdb->query( + $wpdb->prepare( + "DELETE a FROM {$wpdb->options} a + LEFT JOIN {$wpdb->options} b + ON b.option_name = CONCAT( '_site_transient_', SUBSTRING( a.option_name, 25 ) ) + WHERE a.option_name LIKE %s + AND a.option_value < %d + AND b.option_id IS NULL", + $wpdb->esc_like( '_site_transient_timeout_' ) . '%', + time() + ) + ); } elseif ( is_main_site() && is_main_network() ) { // Multisite stores site transients in the sitemeta table. $wpdb->query( @@ -1683,6 +1715,20 @@ function delete_expired_transients( $force_db = false ) { time() ) ); + + // Remove orphaned site transient timeout rows from sitemeta (timeout exists, value row is missing). + $wpdb->query( + $wpdb->prepare( + "DELETE a FROM {$wpdb->sitemeta} a + LEFT JOIN {$wpdb->sitemeta} b + ON b.meta_key = CONCAT( '_site_transient_', SUBSTRING( a.meta_key, 25 ) ) + WHERE a.meta_key LIKE %s + AND a.meta_value < %d + AND b.meta_id IS NULL", + $wpdb->esc_like( '_site_transient_timeout_' ) . '%', + time() + ) + ); } } @@ -2528,9 +2574,7 @@ function delete_site_transient( $transient ) { $option = '_site_transient_' . $transient; $result = delete_site_option( $option ); - if ( $result ) { - delete_site_option( $option_timeout ); - } + delete_site_option( $option_timeout ); } if ( $result ) { diff --git a/tests/phpunit/tests/option/transient.php b/tests/phpunit/tests/option/transient.php index d4f6c6ce43ae9..be850fa6e8bff 100644 --- a/tests/phpunit/tests/option/transient.php +++ b/tests/phpunit/tests/option/transient.php @@ -265,4 +265,86 @@ public function test_nonexistent_key_old_timeout() { ); $this->assertSame( $expected, $a->get_events() ); } + + /** + * Tests that delete_transient() removes an orphaned timeout row when the value row is missing. + * + * set_transient() writes the timeout row before the value row in two separate statements. + * If a request aborts between those two writes, the timeout row is committed but the value + * row never arrives. delete_transient() must still remove the orphaned timeout row. + * + * @ticket 65863 + * + * @covers ::delete_transient + */ + public function test_delete_transient_removes_orphaned_timeout_row() { + global $wpdb; + + $transient = 'test_orphan_timeout'; + + // Simulate an orphaned timeout row by writing it directly without the value row. + add_option( '_transient_timeout_' . $transient, time() + 3600, '', false ); + + // Confirm the value row does not exist and the timeout row does. + $this->assertFalse( get_option( '_transient_' . $transient ), 'Value row should not exist.' ); + $this->assertNotFalse( get_option( '_transient_timeout_' . $transient ), 'Timeout row should exist.' ); + + // delete_transient() should remove the orphaned timeout row. + delete_transient( $transient ); + + $this->assertFalse( + get_option( '_transient_timeout_' . $transient ), + 'Orphaned timeout row should have been removed by delete_transient().' + ); + } + + /** + * Tests that delete_expired_transients() removes orphaned timeout rows whose timestamp has passed. + * + * An orphaned timeout row — a `_transient_timeout_` row without a matching `_transient_` row — + * is invisible to the normal multi-table DELETE used by delete_expired_transients() because the + * JOIN requires both rows to exist. A dedicated LEFT JOIN query must remove them. + * + * @ticket 65863 + * + * @covers ::delete_expired_transients + */ + public function test_delete_expired_transients_removes_orphaned_expired_timeout_row() { + $transient = 'test_orphan_expired'; + + // Simulate an orphaned timeout row that has already expired. + add_option( '_transient_timeout_' . $transient, time() - 1, '', false ); + + // Confirm the value row does not exist and the timeout row does. + $this->assertFalse( get_option( '_transient_' . $transient ), 'Value row should not exist.' ); + $this->assertNotFalse( get_option( '_transient_timeout_' . $transient ), 'Timeout row should exist before cleanup.' ); + + delete_expired_transients(); + + $this->assertFalse( + get_option( '_transient_timeout_' . $transient ), + 'Orphaned expired timeout row should have been removed by delete_expired_transients().' + ); + } + + /** + * Tests that delete_expired_transients() does not remove an orphaned timeout row that has not yet expired. + * + * @ticket 65863 + * + * @covers ::delete_expired_transients + */ + public function test_delete_expired_transients_keeps_orphaned_future_timeout_row() { + $transient = 'test_orphan_future'; + + // Simulate an orphaned timeout row that has not yet expired. + add_option( '_transient_timeout_' . $transient, time() + 3600, '', false ); + + delete_expired_transients(); + + $this->assertNotFalse( + get_option( '_transient_timeout_' . $transient ), + 'Orphaned timeout row with a future expiry should not be removed by delete_expired_transients().' + ); + } } From e1a1ac2afc4d4873bc0a9ec244b993a3c762dc11 Mon Sep 17 00:00:00 2001 From: HasnainAshfaq Date: Fri, 21 Aug 2026 03:56:16 +0500 Subject: [PATCH 2/2] Tests: Query DB directly in delete_expired_transients orphan tests. delete_expired_transients() uses raw SQL and does not flush the option cache, so asserting via get_option() would read the cached value and fail. Switch both new orphan tests to query wpdb directly so they verify the physical row state. See https://core.trac.wordpress.org/ticket/65863 --- tests/phpunit/tests/option/transient.php | 45 ++++++++++++++++++------ 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/tests/phpunit/tests/option/transient.php b/tests/phpunit/tests/option/transient.php index be850fa6e8bff..7de40b697b133 100644 --- a/tests/phpunit/tests/option/transient.php +++ b/tests/phpunit/tests/option/transient.php @@ -310,20 +310,34 @@ public function test_delete_transient_removes_orphaned_timeout_row() { * @covers ::delete_expired_transients */ public function test_delete_expired_transients_removes_orphaned_expired_timeout_row() { - $transient = 'test_orphan_expired'; + global $wpdb; + + $transient = 'test_orphan_expired'; + $option_timeout = '_transient_timeout_' . $transient; // Simulate an orphaned timeout row that has already expired. - add_option( '_transient_timeout_' . $transient, time() - 1, '', false ); + add_option( $option_timeout, time() - 1, '', false ); // Confirm the value row does not exist and the timeout row does. $this->assertFalse( get_option( '_transient_' . $transient ), 'Value row should not exist.' ); - $this->assertNotFalse( get_option( '_transient_timeout_' . $transient ), 'Timeout row should exist before cleanup.' ); + $this->assertNotFalse( get_option( $option_timeout ), 'Timeout row should exist before cleanup.' ); delete_expired_transients(); - $this->assertFalse( - get_option( '_transient_timeout_' . $transient ), - 'Orphaned expired timeout row should have been removed by delete_expired_transients().' + /* + * delete_expired_transients() uses raw SQL and does not clear the option cache. + * Query the database directly to verify the row was physically removed. + */ + $row = $wpdb->get_var( + $wpdb->prepare( + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", + $option_timeout + ) + ); + + $this->assertNull( + $row, + 'Orphaned expired timeout row should have been removed from the database by delete_expired_transients().' ); } @@ -335,15 +349,26 @@ public function test_delete_expired_transients_removes_orphaned_expired_timeout_ * @covers ::delete_expired_transients */ public function test_delete_expired_transients_keeps_orphaned_future_timeout_row() { - $transient = 'test_orphan_future'; + global $wpdb; + + $transient = 'test_orphan_future'; + $option_timeout = '_transient_timeout_' . $transient; // Simulate an orphaned timeout row that has not yet expired. - add_option( '_transient_timeout_' . $transient, time() + 3600, '', false ); + add_option( $option_timeout, time() + 3600, '', false ); delete_expired_transients(); - $this->assertNotFalse( - get_option( '_transient_timeout_' . $transient ), + // Query the database directly to confirm the row is still present. + $row = $wpdb->get_var( + $wpdb->prepare( + "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", + $option_timeout + ) + ); + + $this->assertNotNull( + $row, 'Orphaned timeout row with a future expiry should not be removed by delete_expired_transients().' ); }