diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 8bd6a1821162e..67ff3596e671d 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -1372,6 +1372,10 @@ function wp_filter_default_autoload_value_via_option_size( $autoload, $option, $ /** * 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 * * @param string $transient Transient name. Expected to not be SQL-escaped. @@ -1397,9 +1401,7 @@ function delete_transient( $transient ) { $option = '_transient_' . $transient; $result = delete_option( $option ); - if ( $result ) { - delete_option( $option_timeout ); - } + delete_option( $option_timeout ); } if ( $result ) { @@ -1655,6 +1657,19 @@ function delete_expired_transients( $force_db = false ) { ) ); + $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 +1684,19 @@ function delete_expired_transients( $force_db = false ) { time() ) ); + + $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( @@ -1677,12 +1705,27 @@ function delete_expired_transients( $force_db = false ) { WHERE a.meta_key LIKE %s AND a.meta_key NOT LIKE %s AND b.meta_key = CONCAT( '_site_transient_timeout_', SUBSTRING( a.meta_key, 17 ) ) + AND b.site_id = a.site_id AND b.meta_value < %d", $wpdb->esc_like( '_site_transient_' ) . '%', $wpdb->esc_like( '_site_transient_timeout_' ) . '%', time() ) ); + + $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 ) ) + AND b.site_id = a.site_id + WHERE a.meta_key LIKE %s + AND a.meta_value < %d + AND b.meta_id IS NULL", + $wpdb->esc_like( '_site_transient_timeout_' ) . '%', + time() + ) + ); } } @@ -2503,9 +2546,13 @@ function update_network_option( $network_id, $option, $value ) { /** * Deletes a site transient. * + * If an orphaned site 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.9.0 * - * @param string $transient Transient name. Expected to not be SQL-escaped. + * @param string $transient Site transient name. Expected to not be SQL-escaped. * @return bool True if the transient was deleted, false otherwise. */ function delete_site_transient( $transient ) { @@ -2528,9 +2575,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/siteTransient.php b/tests/phpunit/tests/option/siteTransient.php index daabc1edf5a97..a40bb7d10b931 100644 --- a/tests/phpunit/tests/option/siteTransient.php +++ b/tests/phpunit/tests/option/siteTransient.php @@ -155,4 +155,300 @@ public function test_site_transients_not_stored_in_options_table_on_ms() { $this->assertNull( $option, 'Querying option table should not return transient on multisite.' ); } + + /** + * Tests that delete_site_transient() removes an orphaned timeout row. + * + * @ticket 65863 + */ + public function test_delete_site_transient_removes_orphaned_timeout() { + $key = 'orphaned_timeout'; + + set_site_transient( $key, 'value', 3600 ); + + // Remove the value row, leaving only the timeout row. + delete_site_option( '_site_transient_' . $key ); + + $this->assertFalse( get_site_option( '_site_transient_' . $key ) ); + $this->assertNotFalse( get_site_option( '_site_transient_timeout_' . $key ) ); + + delete_site_transient( $key ); + + $this->assertFalse( get_site_option( '_site_transient_' . $key ) ); + $this->assertFalse( get_site_option( '_site_transient_timeout_' . $key ) ); + } + + /** + * Tests that delete_expired_transients() removes expired orphaned site transient timeouts on single site. + * + * @ticket 65863 + * @group ms-excluded + * + * @covers ::delete_expired_transients + */ + public function test_delete_expired_site_transients_orphaned_timeouts_single_site() { + global $wpdb; + + $now = time(); + + // Expired orphaned timeout (should be deleted). + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", + '_site_transient_timeout_expired_orphan', + $now - 100 + ) + ); + + // Unexpired orphaned timeout (should be kept). + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", + '_site_transient_timeout_unexpired_orphan', + $now + 100 + ) + ); + + // Expired pair (should be deleted). + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", + '_site_transient_expired_pair', + 'value' + ) + ); + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", + '_site_transient_timeout_expired_pair', + $now - 100 + ) + ); + + // Unexpired pair (should be kept). + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", + '_site_transient_unexpired_pair', + 'value' + ) + ); + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", + '_site_transient_timeout_unexpired_pair', + $now + 100 + ) + ); + + delete_expired_transients( true ); + + $this->assertNull( + $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = '_site_transient_timeout_expired_orphan'" ), + 'Expired orphan site transient timeout should be deleted.' + ); + $this->assertNotNull( + $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = '_site_transient_timeout_unexpired_orphan'" ), + 'Unexpired orphan site transient timeout should be retained.' + ); + + $this->assertNull( + $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = '_site_transient_expired_pair'" ), + 'Expired site transient value should be deleted.' + ); + $this->assertNull( + $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = '_site_transient_timeout_expired_pair'" ), + 'Expired site transient timeout should be deleted.' + ); + + $this->assertSame( + 'value', + $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = '_site_transient_unexpired_pair'" ), + 'Unexpired site transient value should be retained.' + ); + $this->assertNotNull( + $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = '_site_transient_timeout_unexpired_pair'" ), + 'Unexpired site transient timeout should be retained.' + ); + } + + /** + * Tests that delete_expired_transients() removes expired orphaned site transient timeouts on multisite. + * + * @ticket 65863 + * @group ms-required + * + * @covers ::delete_expired_transients + */ + public function test_delete_expired_site_transients_orphaned_timeouts_multisite() { + global $wpdb; + + $now = time(); + $site_id = get_current_network_id(); + + // Expired orphaned timeout (should be deleted). + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->sitemeta} (site_id, meta_key, meta_value) VALUES (%d, %s, %s)", + $site_id, + '_site_transient_timeout_expired_orphan', + $now - 100 + ) + ); + + // Unexpired orphaned timeout (should be kept). + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->sitemeta} (site_id, meta_key, meta_value) VALUES (%d, %s, %s)", + $site_id, + '_site_transient_timeout_unexpired_orphan', + $now + 100 + ) + ); + + // Expired pair (should be deleted). + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->sitemeta} (site_id, meta_key, meta_value) VALUES (%d, %s, %s)", + $site_id, + '_site_transient_expired_pair', + 'value' + ) + ); + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->sitemeta} (site_id, meta_key, meta_value) VALUES (%d, %s, %s)", + $site_id, + '_site_transient_timeout_expired_pair', + $now - 100 + ) + ); + + // Unexpired pair (should be kept). + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->sitemeta} (site_id, meta_key, meta_value) VALUES (%d, %s, %s)", + $site_id, + '_site_transient_unexpired_pair', + 'value' + ) + ); + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->sitemeta} (site_id, meta_key, meta_value) VALUES (%d, %s, %s)", + $site_id, + '_site_transient_timeout_unexpired_pair', + $now + 100 + ) + ); + + delete_expired_transients( true ); + + $this->assertNull( + $wpdb->get_var( "SELECT meta_value FROM {$wpdb->sitemeta} WHERE meta_key = '_site_transient_timeout_expired_orphan'" ), + 'Expired orphan site transient timeout should be deleted in multisite.' + ); + $this->assertNotNull( + $wpdb->get_var( "SELECT meta_value FROM {$wpdb->sitemeta} WHERE meta_key = '_site_transient_timeout_unexpired_orphan'" ), + 'Unexpired orphan site transient timeout should be retained in multisite.' + ); + + $this->assertNull( + $wpdb->get_var( "SELECT meta_value FROM {$wpdb->sitemeta} WHERE meta_key = '_site_transient_expired_pair'" ), + 'Expired site transient value should be deleted in multisite.' + ); + $this->assertNull( + $wpdb->get_var( "SELECT meta_value FROM {$wpdb->sitemeta} WHERE meta_key = '_site_transient_timeout_expired_pair'" ), + 'Expired site transient timeout should be deleted in multisite.' + ); + + $this->assertSame( + 'value', + $wpdb->get_var( "SELECT meta_value FROM {$wpdb->sitemeta} WHERE meta_key = '_site_transient_unexpired_pair'" ), + 'Unexpired site transient value should be retained in multisite.' + ); + $this->assertNotNull( + $wpdb->get_var( "SELECT meta_value FROM {$wpdb->sitemeta} WHERE meta_key = '_site_transient_timeout_unexpired_pair'" ), + 'Unexpired site transient timeout should be retained in multisite.' + ); + } + + /** + * Tests that delete_expired_transients() does not delete transient rows belonging to other networks. + * + * @ticket 65863 + * @ticket 65969 + * @group ms-required + * + * @covers ::delete_expired_transients + */ + public function test_delete_expired_site_transients_does_not_affect_other_networks() { + global $wpdb; + + $now = time(); + $network_1 = get_current_network_id(); + $network_2 = self::factory()->network->create(); + + // Network 1: Unexpired Valid Pair (should be kept). + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->sitemeta} (site_id, meta_key, meta_value) VALUES (%d, %s, %s)", + $network_1, + '_site_transient_crossnet', + 'value' + ) + ); + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->sitemeta} (site_id, meta_key, meta_value) VALUES (%d, %s, %s)", + $network_1, + '_site_transient_timeout_crossnet', + $now + 3600 + ) + ); + + // Network 2: Expired Orphaned Timeout (should be deleted). + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->sitemeta} (site_id, meta_key, meta_value) VALUES (%d, %s, %s)", + $network_2, + '_site_transient_timeout_crossnet', + $now - 100 + ) + ); + + delete_expired_transients( true ); + + // Network 2's expired orphan timeout should be deleted. + $this->assertNull( + $wpdb->get_var( + $wpdb->prepare( + "SELECT meta_value FROM {$wpdb->sitemeta} WHERE site_id = %d AND meta_key = '_site_transient_timeout_crossnet'", + $network_2 + ) + ), + "Network 2's expired orphan timeout should be deleted." + ); + + // Network 1's valid pair must remain completely untouched! + $this->assertSame( + 'value', + $wpdb->get_var( + $wpdb->prepare( + "SELECT meta_value FROM {$wpdb->sitemeta} WHERE site_id = %d AND meta_key = '_site_transient_crossnet'", + $network_1 + ) + ), + "Network 1's valid transient value should be retained." + ); + $this->assertNotNull( + $wpdb->get_var( + $wpdb->prepare( + "SELECT meta_value FROM {$wpdb->sitemeta} WHERE site_id = %d AND meta_key = '_site_transient_timeout_crossnet'", + $network_1 + ) + ), + "Network 1's unexpired transient timeout should be retained." + ); + } } diff --git a/tests/phpunit/tests/option/transient.php b/tests/phpunit/tests/option/transient.php index d4f6c6ce43ae9..07620844f9b38 100644 --- a/tests/phpunit/tests/option/transient.php +++ b/tests/phpunit/tests/option/transient.php @@ -265,4 +265,135 @@ public function test_nonexistent_key_old_timeout() { ); $this->assertSame( $expected, $a->get_events() ); } + + /** + * Tests that delete_transient() removes an orphaned timeout row. + * + * @ticket 65863 + */ + public function test_delete_transient_removes_orphaned_timeout() { + $key = 'orphaned_timeout'; + + set_transient( $key, 'value', 3600 ); + + // Remove the value row, leaving only the timeout row. + delete_option( '_transient_' . $key ); + + $this->assertFalse( get_option( '_transient_' . $key ) ); + $this->assertNotFalse( get_option( '_transient_timeout_' . $key ) ); + + delete_transient( $key ); + + $this->assertFalse( get_option( '_transient_' . $key ) ); + $this->assertFalse( get_option( '_transient_timeout_' . $key ) ); + } + + /** + * Tests that delete_expired_transients() removes expired orphaned timeouts + * while leaving unexpired orphaned timeouts, unexpired transients, and timeout-less transients intact. + * + * @ticket 65863 + * + * @covers ::delete_expired_transients + */ + public function test_delete_expired_transients_with_orphaned_timeouts() { + global $wpdb; + + $now = time(); + + // 1. Expired orphaned timeout (should be deleted). + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", + '_transient_timeout_expired_orphan', + $now - 100 + ) + ); + + // 2. Unexpired orphaned timeout (should be kept). + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", + '_transient_timeout_unexpired_orphan', + $now + 100 + ) + ); + + // 3. Expired pair (should be deleted). + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", + '_transient_expired_pair', + 'value' + ) + ); + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", + '_transient_timeout_expired_pair', + $now - 100 + ) + ); + + // 4. Unexpired pair (should be kept). + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", + '_transient_unexpired_pair', + 'value' + ) + ); + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", + '_transient_timeout_unexpired_pair', + $now + 100 + ) + ); + + // 5. Value without timeout (should be kept). + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", + '_transient_no_timeout', + 'value' + ) + ); + + delete_expired_transients( true ); + + $this->assertNull( + $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = '_transient_timeout_expired_orphan'" ), + 'Expired orphan timeout should be deleted.' + ); + $this->assertNotNull( + $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = '_transient_timeout_unexpired_orphan'" ), + 'Unexpired orphan timeout should be retained.' + ); + + $this->assertNull( + $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = '_transient_expired_pair'" ), + 'Expired transient value should be deleted.' + ); + $this->assertNull( + $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = '_transient_timeout_expired_pair'" ), + 'Expired transient timeout should be deleted.' + ); + + $this->assertSame( + 'value', + $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = '_transient_unexpired_pair'" ), + 'Unexpired transient value should be retained.' + ); + $this->assertNotNull( + $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = '_transient_timeout_unexpired_pair'" ), + 'Unexpired transient timeout should be retained.' + ); + + $this->assertSame( + 'value', + $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = '_transient_no_timeout'" ), + 'Transient without timeout should be retained.' + ); + } }