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..7de40b697b133 100644 --- a/tests/phpunit/tests/option/transient.php +++ b/tests/phpunit/tests/option/transient.php @@ -265,4 +265,111 @@ 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() { + global $wpdb; + + $transient = 'test_orphan_expired'; + $option_timeout = '_transient_timeout_' . $transient; + + // Simulate an orphaned timeout row that has already expired. + 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( $option_timeout ), 'Timeout row should exist before cleanup.' ); + + 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().' + ); + } + + /** + * 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() { + global $wpdb; + + $transient = 'test_orphan_future'; + $option_timeout = '_transient_timeout_' . $transient; + + // Simulate an orphaned timeout row that has not yet expired. + add_option( $option_timeout, time() + 3600, '', false ); + + delete_expired_transients(); + + // 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().' + ); + } }