Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 50 additions & 6 deletions src/wp-includes/option.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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.
*
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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()
)
);
}
}

Expand Down Expand Up @@ -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 ) {
Expand Down
107 changes: 107 additions & 0 deletions tests/phpunit/tests/option/transient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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().'
);
}
}
Loading