Skip to content

Upgrade/Install: Retry an update check whose result could not be stored. - #12596

Open
gunjanjaswal wants to merge 9 commits into
WordPress:trunkfrom
gunjanjaswal:fix/64550-update-transient-failure-lock
Open

gunjanjaswal wants to merge 9 commits into
WordPress:trunkfrom
gunjanjaswal:fix/64550-update-transient-failure-lock

Conversation

@gunjanjaswal

@gunjanjaswal gunjanjaswal commented Jul 19, 2026 •

Copy link
Copy Markdown

wp_version_check(), wp_update_plugins() and wp_update_themes() record a last_checked timestamp before contacting the API, then store the result without checking whether that write succeeded. When it fails, the timestamp is left behind on top of stale data, so later checks return early until the timeout expires, then fail and re-arm the lock the same way. The site stops being offered updates at all, security updates included, while reporting itself up to date.

This checks the return value and resets last_checked when the result was not stored, so the next request retries instead of trusting a check that never persisted.

One wrinkle worth reviewer attention: a false return is not conclusive on its own. update_site_option() also returns false when the value it is asked to store already matches the stored value, and that happens on the happy path whenever the API answers within the same second the lock was taken. Rolling back on every false would send the site back out to the API on each request, so the intended value is compared against the lock object before anything is rolled back.

The other open question is the retry interval. Resetting last_checked to 0 means a site whose payload genuinely cannot be stored retries on the next request rather than once every twelve hours. That turns a silent permanent failure into a noisy recoverable one, which seems like the right trade, but if a backoff is preferred over an immediate retry that is an easy change.

Tests cover the plugin path: the happy path staying unchanged, the lock resetting when the write is rejected, the next check actually re-running, and the unchanged-value case not being mistaken for a failure. Core and themes share the same code path.

Trac ticket: https://core.trac.wordpress.org/ticket/64550

Use of AI Tools

AI assistance: Yes. Tool: Claude Code. Used for drafting the implementation, tests, and description. I reviewed, tested, and understand every change, and I take responsibility for it.

wp_version_check(), wp_update_plugins() and wp_update_themes() record a
last_checked timestamp before contacting the API so that concurrent requests do
not all issue the same remote request. The write that stores the result was made
without looking at its return value, so a failure left that timestamp behind on
stale data. Subsequent checks returned early until the timeout expired, then
failed and re-armed the lock the same way, leaving the site permanently unable to
see updates, including security updates, while reporting itself up to date.

Check the return value and reset last_checked when the result was not stored, so
the next request retries the check.

A false return is not conclusive on its own, since update_site_option() also
returns false when the value it is asked to store is already the stored value.
That happens routinely here, because the result of a check that found nothing new
is identical to the object stored when the lock was taken whenever the API
responds within the same second. The two are compared before the lock is rolled
back so an unchanged value is not mistaken for a failure.

Props madhazelnut, siliconforks, soyebsalar01.
Fixes #64550.
@github-actions

github-actions Bot commented Jul 19, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props gunjanjaswal, adamsilverstein, siliconforks, madhazelnut.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

…e stored

The two tests that force the result write to fail also trigger the
wp_trigger_error() warning the fix emits, which the test suite promotes to an
error. Capture the warning with an error handler, following the approach used
in tests/phpunit/tests/user.php, and assert it was raised so the developer
notice stays covered.
… use

set_site_transient() writes through wp_cache_set() when an external object
cache is present, so the pre_update_site_option filter these two tests rely on
to reject the write never runs and no failure is simulated. Skip them in that
configuration, matching tests/phpunit/tests/option/siteTransient.php.
Comment thread src/wp-includes/update.php Outdated
}

$lock->last_checked = 0;
set_site_transient( $transient, $lock );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not return the result of set_site_transient - couldn't it still fail here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, done in 5c8e74a. The helper now returns the result of the reset write instead of assuming it stuck. Since the reset stores the original lock with last_checked set to 0, a false here is unambiguously a failed write (not the unchanged-value false the payload path guards against), so the caller only announces the retry when the reset actually persisted. Added a test for the case where even the reset write does not land.

@adamsilverstein

Copy link
Copy Markdown
Member

Questions:

  • Can we just continue with the update in _maybe_update_core when the transient is missing?
  • Can we fix the underlying issue that prevents the transient from being set correctly in the first place instead of papering over it?

Return the result of the set_site_transient() call that rolls the lock back,
rather than assuming it stored. The rollback write can fail the same way the
original result did, and when it does the caller should not announce that the
check will run again. Add a test covering a reset write that does not persist.

Follow-up to feedback on the retry-on-failed-store change for #64550.
@gunjanjaswal

Copy link
Copy Markdown
Author

Thanks Adam, the other two are worth answering directly.

On continuing in _maybe_update_core() when the transient is missing: that would help core auto-updates, but it doesn't cover the reported failure. The stale last_checked blocks the check itself for plugins and themes too, and it's what makes the admin screens report the site as up to date while it quietly stops receiving updates. _maybe_update_core() is one consumer downstream of that; resetting the lock restores all three paths at once, rather than patching a single caller.

On fixing the underlying issue instead: I'd gently push back that there isn't one bug to fix here, it's a class of storage failures core can't fully prevent. Whether the payload stores depends on the environment: a non-utf8mb4 column choking on a 4-byte character, an object cache backend returning false, a write failing under load. What core can own is that a transient storage failure doesn't become a permanent one. Today a single failed write locks the site out of all updates, security releases included, until someone notices and clears the transient by hand. If the ticket turns up a specific reproducible cause worth fixing on top of this, I'm glad to take that separately, but it wouldn't remove the need to fail safe when the write doesn't land.

Happy to swap the immediate retry for a short backoff too if resetting straight to 0 reads as too eager.

@adamsilverstein

Copy link
Copy Markdown
Member

I'd gently push back that there isn't one bug to fix here, it's a class of storage failures core can't fully prevent. Whether the payload stores depends on the environment: a non-utf8mb4 column choking on a 4-byte character,

Right - I'm not arguing against having better error handling, but rather avoiding the error entirely for the class of issues the ticket was opened for - caused as I understand it by the use of a "4 byte character" emoji in their changelog.

Are these characters winding up as part of the transient data, and if so can we strip these problematic characters from the data before trying to set the transient since we know they may cause an issue? Then the transient set wouldn't fail and everything would work as expected, correct?

@siliconforks

Copy link
Copy Markdown

Right - I'm not arguing against having better error handling, but rather avoiding the error entirely for the class of issues the ticket was opened for - caused as I understand it by the use of a "4 byte character" emoji in their changelog.

Are these characters winding up as part of the transient data, and if so can we strip these problematic characters from the data before trying to set the transient since we know they may cause an issue? Then the transient set wouldn't fail and everything would work as expected, correct?

@adamsilverstein have you seen my PR #13282? It basically attempts to do this.

@adamsilverstein

Copy link
Copy Markdown
Member

@gunjanjaswal - Thanks for your contribution here. Please note that while it is absolutely fine to make AI driven contributions including writing, the WordPress project policy is that you attribute that writing or code so it is clear. Adding a simple line at the top such as "This response written by Claude" is sufficient. See https://make.wordpress.org/ai/handbook/ai-guidelines/ for additional details.

@adamsilverstein

Copy link
Copy Markdown
Member

@adamsilverstein have you seen my PR #13282? It basically attempts to do this.

Oh great, thanks for sharing that @siliconforks

@gunjanjaswal

Copy link
Copy Markdown
Author

Thanks Adam, and no problem on the disclosure, I've added a note to the description.

On the emoji case: yes, those 4-byte characters land in the changelog/response payload that goes into the transient, and stripping them before the write is a sound fix for that specific trigger. @siliconforks' #13282 looks like the right home for it.

I'd frame the two as complementary rather than either/or. Stripping prevents the known emoji case, but the same permanent lockout happens whenever a store fails for a reason sanitizing can't reach: an object cache backend returning false, a write failing under load, a value that's valid but rejected by a filter. This PR is the fail-safe under all of those. It notices the store didn't land and lets the next request retry, instead of the site going quiet until someone clears the transient by hand. Happy to rebase around #13282 if it merges first; the retry path sits independently of how the payload is built.

@gunjanjaswal

Copy link
Copy Markdown
Author

@adamsilverstein gentle nudge on this when you get a chance — I folded in your earlier feedback, so it should be ready for another look. No rush, just keeping it on your radar.

@adamsilverstein

Copy link
Copy Markdown
Member

Thanks for the nudge @gunjanjaswal - I'll take another look soon!

@siliconforks

Copy link
Copy Markdown

Looking at this PR, I confess I don't entirely understand what it is intended to do. It appears to be attempting to detect errors and then reset the last_checked timestamp to 0. But that doesn't really fix anything - in fact, it just seems to make the problem worse:

  • Before, the check for updates might fail, but it would update the timestamp, so it would not check again for N hours (or whatever the timeout is).
  • With this PR, it's going to be checking for updates, failing to store the transient, and setting the timestamp to 0, so it will immediately check for updates again, presumably fail to store the transient again, and so on, continuously. So now you have a potential performance problem in addition to the problem of not getting updates.

This PR also logs a message when an error is detected. In principle I agree that this is a good idea, but currently there doesn't seem to be a reliable way to do this. The problem is that set_site_transient() doesn't really have a way of signalling errors - if it returns false, that might indicate an error, or it might simply be that the transient was already set to the requested value. This PR attempts to work around that by trying to detect the latter case, but that is going to be prone to race conditions.

It seems fairly easy to trigger the race condition with WP-CLI using the following steps:

  1. Apply this PR.

  2. Make sure you have some plugins installed. (It's probably best to have several plugins installed as it will probably make the update check take longer and be more likely to trigger the race condition.)

  3. Delete the update_plugins transient:

    wp transient delete --network update_plugins
    
  4. Run two simultaneous update checks:

    wp plugin update --dry-run --all & wp plugin update --dry-run --all
    

In my testing this seems to trigger the race condition at least 50% of the time, and you will get a spurious error message: "The result of the plugin update check could not be stored. The check will run again on the next request."

Drop _wp_maybe_reset_update_check_lock and the last_checked reset. Resetting
the lock to 0 made a persistent storage failure (e.g. a 4-byte character in a
non-utf8mb4 wp_options column) re-run the check on every request, a busy loop.

Key the warning on $wpdb->last_error instead of a false return from
set_site_transient(). A false return is ambiguous — update_option() also
returns false when the value is already the stored value, which happens
routinely when two checks run concurrently — so the previous value comparison
was prone to a spurious warning under concurrency. A genuine write failure sets
last_error; an unchanged write runs no query and sets none, so it stays silent.

The lock now expires on its own timeout as before; the change only makes a real
storage failure visible rather than silent. Tests updated accordingly.
@gunjanjaswal

Copy link
Copy Markdown
Author

You're right on both counts, thanks for the careful analysis — I've reworked it.

The busy loop. Resetting last_checked to 0 does turn a persistent storage failure into a per-request re-check, which is worse than the stale-lock behaviour it was trying to fix. I've dropped the reset (and the _wp_maybe_reset_update_check_lock helper) entirely. The lock now expires on its own timeout exactly as before — the change no longer touches recovery timing at all.

The false-return race. Agreed that a false return can't distinguish a failed write from an unchanged value, and comparing the payload to the pre-fetch lock is racy under concurrency, as your WP-CLI repro shows. The warning now keys off $wpdb->last_error instead: a genuine write failure sets it, while an unchanged write runs no query and sets nothing, so the concurrent-checks case stays silent. That should remove the spurious message from your repro — if you get a chance to run it again against the updated branch I'd be glad to know it's clean on your end too.

So the PR is now scoped down to just surfacing a real storage failure as a warning, rather than trying to auto-recover from it. That keeps the diagnostic value @adamsilverstein liked without the retry loop or the race.

…uard

The lock stored before the API call carries no `checked` map, so core re-runs
the check on the next request regardless of `last_checked` — that is existing
behavior, not something this change controls. Narrow the test to what the rework
guarantees: a failed write leaves `last_checked` in place rather than resetting
it to 0.
@adamsilverstein

Copy link
Copy Markdown
Member

Now that the PR scope is reduced to error messages, it doesn't seem to solve the original issue reported in https://core.trac.wordpress.org/ticket/64550 where updates break if the transient can't be stored. We should keep looking into this, there should be a way to resolve the issue.

@siliconforks

Copy link
Copy Markdown

Fixing the issue in https://core.trac.wordpress.org/ticket/64550 is the goal of PR #13282. This PR - PR #12596 - just improves the error detection and handling.

@adamsilverstein

Copy link
Copy Markdown
Member

Thanks @siliconforks and @gunjanjaswal for all the back and forth here. I think the cleanest path forward is to encode the problem characters before we store the transient, so the write never fails in the first place.

Claude dug into both PRs and the wpdb code path, here is the approach it suggests:

When the option_value column isn't utf8mb4, wpdb::process_fields() rejects the whole write as soon as the payload contains a 4-byte character (see strip_invalid_text() in class-wpdb.php). The lock copy with a fresh last_checked is all that's left, so the site reports itself up to date until the next check fails the same way.

Rather than detecting the failure after the fact, the payload could be encoded before it is written. wp_update_plugins() already runs a $sanitize_plugin_update_payload closure right before set_site_transient(); that closure (plus an equivalent for themes and core) could convert any 4-byte characters to numeric HTML entities when the storage column isn't utf8mb4:

$table   = is_multisite() ? $wpdb->sitemeta : $wpdb->options;
$column  = is_multisite() ? 'meta_value' : 'option_value';

if ( 'utf8mb4' !== $wpdb->get_col_charset( $table, $column ) ) {
	$updates = map_deep(
		$updates,
		static function ( $value ) {
			if ( ! is_string( $value ) ) {
				return $value;
			}
			return preg_replace_callback(
				'/[\x{10000}-\x{10FFFF}]/u',
				static function ( $matches ) {
					return '&#x' . dechex( mb_ord( $matches[0] ) ) . ';';
				},
				$value
			);
		}
	);
}

A few reasons this seems like a better fit than the current approaches:

  • Nothing is lost. Changelogs and upgrade notices are rendered as HTML, so the emoji still display, where stripping them (as Sanitize 'update_plugins' and 'update_themes' transients. #13282 does) removes them.
  • It catches every 4-byte character, not just the ones in wp_encode_emoji()'s list.
  • It checks the right table on multisite, where site transients live in sitemeta.meta_value rather than options.option_value. Sanitize 'update_plugins' and 'update_themes' transients. #13282 currently checks $wpdb->options in both cases.
  • It covers update_core too, not only plugins and themes.
  • There is precedent: wp_insert_post() already encodes emoji on utf8 tables, and sanitize_option() encodes before stripping for blogname/blogdescription.
  • No race, no retry loop, and no reliance on set_site_transient() returning false or on $wpdb->last_error, which can be stale when the write short-circuits without running a query.

Worth noting this only addresses the charset trigger from the ticket. A backoff on last_checked (retry in an hour instead of twelve, rather than resetting to 0) could still be a reasonable follow up for other storage failures, but it doesn't need to block the root cause fix.

@siliconforks would you be up for reworking #13282 along these lines? @gunjanjaswal happy to have you collaborate there too, or pick up the backoff piece separately. What do you both think?

@siliconforks

Copy link
Copy Markdown

Claude dug into both PRs and the wpdb code path, here is the approach it suggests:

When the option_value column isn't utf8mb4, wpdb::process_fields() rejects the whole write as soon as the payload contains a 4-byte character (see strip_invalid_text() in class-wpdb.php). The lock copy with a fresh last_checked is all that's left, so the site reports itself up to date until the next check fails the same way.
Rather than detecting the failure after the fact, the payload could be encoded before it is written. wp_update_plugins() already runs a $sanitize_plugin_update_payload closure right before set_site_transient(); that closure (plus an equivalent for themes and core) could convert any 4-byte characters to numeric HTML entities when the storage column isn't utf8mb4:

$table   = is_multisite() ? $wpdb->sitemeta : $wpdb->options;
$column  = is_multisite() ? 'meta_value' : 'option_value';

if ( 'utf8mb4' !== $wpdb->get_col_charset( $table, $column ) ) {
	$updates = map_deep(
		$updates,
		static function ( $value ) {
			if ( ! is_string( $value ) ) {
				return $value;
			}
			return preg_replace_callback(
				'/[\x{10000}-\x{10FFFF}]/u',
				static function ( $matches ) {
					return '&#x' . dechex( mb_ord( $matches[0] ) ) . ';';
				},
				$value
			);
		}
	);
}

I see a few things worth noting about Claude's suggested code above:

  1. It assumes that the content of the update fields is always HTML. That might not always be the case, although it may be reasonable to simply assume that it is and that you can just use HTML entities.

  2. It assumes that transients are always stored in the database. This means it ends up running the sanitization code even if there is no need for it because transients are being stored elsewhere. (The current approach in PR #13282 only runs if transients are being stored in the database.)

  3. It assumes that, if the database character set is not utf8mb4, then it must be utf8mb3. That's probably the most likely scenario in 2026 (if a WordPress site isn't using at least utf8mb3, then there are probably other things broken besides just updates). Still, in theory the current approach in PR #13282 should work even if the database character set is something other than utf8mb3. (I haven't tested it with anything other than utf8mb3 though.)

I'm not sure if any of those are worth worrying about in practice. Thoughts?

I also note that Claude seems to be hallucinating in some places here:

Again, I don't think you can necessarily be certain that the content is HTML (though it may be reasonable to just assume that it is).

  • It catches every 4-byte character, not just the ones in wp_encode_emoji()'s list.

This seems like a hallucination: PR #13282 does not use wp_encode_emoji() at all. (Possibly Claude is looking at the approach used for sanitizing blogdescription and blogname?)

Again, this seems like a hallucination: PR #13282 does not check $wpdb->options at all.

  • It covers update_core too, not only plugins and themes.

I'm not sure whether this is actually worth doing for core updates, as they seem unlikely be affected by this issue.

  • There is precedent: wp_insert_post() already encodes emoji on utf8 tables, and sanitize_option() encodes before stripping for blogname/blogdescription.

One difference is that those are known to always be HTML.

  • No race, no retry loop, and no reliance on set_site_transient() returning false or on $wpdb->last_error, which can be stale when the write short-circuits without running a query.

I'm not sure this eliminates the need to check for errors - it may still be a good idea to check for errors anyway.

Hallucinations aside, Claude's basic proposal seems reasonable. I can rework PR #13282 along those lines if you think that's the best approach.

@adamsilverstein

Copy link
Copy Markdown
Member

I'm not sure this eliminates the need to check for errors - it may still be a good idea to check for errors anyway.

As long as we can reliably detect them, adding makes sense.

Again, I don't think you can necessarily be certain that the content is HTML (though it may be reasonable to just assume that it is).

Hmm, good point. It would be good to cover this in a test to confirm or change expected behavior.

Again, this seems like a hallucination: #13282 does not check $wpdb->options at all.

Oh - some of the hallucinations may have been from an earlier part of my session with Claude exploring several possible approaches to fixing the underlying issue. I should have kept the final comment free of those.

I'm not sure whether this is actually worth doing for core updates, as they seem unlikely be affected by this issue.

Right, we only need this for themes and plugins!

It assumes that, if the database character set is not utf8mb4, then it must be utf8mb3. That's probably the most likely scenario in 2026 (if a WordPress site isn't using at least utf8mb3, then there are probably other things broken besides just updates). Still, in theory the current approach in #13282 should work even if the database character set is something other than utf8mb3. (I haven't tested it with anything other than utf8mb3 though.)

Hmm, I reviewed the approach in #13282 again and actually think its better than what Claude is proposing here. lets stick with what you have. Thanks for reviewing the suggestion anyway.

@gunjanjaswal

Copy link
Copy Markdown
Author

On the "as long as we can reliably detect them" point, I went through the write path for the charset case and I think detection holds up here. When a 4-byte character hits a non-utf8mb4 column, wpdb doesn't fail at the query. It fails before the query runs:

  • strip_invalid_text() builds its validation regex with the 4-byte sequences included only when the column charset is utf8mb4. On a utf8/utf8mb3 column those bytes fall through to the catch-all and get stripped, so the converted string no longer matches the input.
  • process_fields() sees $data !== $converted_data, sets $wpdb->last_error to "Processing the value for the following field failed: option_value…", and returns false.
  • update() gets that false and returns before it ever calls query().

So on this exact path last_error is set, and it's set by process_fields() itself rather than carried over from a query. That's the reverse of the "it can be stale when the write short-circuits without running a query" concern: the error is written precisely because no query runs. And every query() runs flush(), which clears last_error, so the successful get_option read that happens just before the write leaves it empty going in. The check isn't catching a leftover from something earlier.

That fits the direction you landed on: #13282 stays the root-cause fix (encode before storing, nothing dropped), and this PR is the detection layer for storage failures #13282 doesn't head off.

One thing I'd like to tighten based on your note about covering it in a test: I can make the failing-write test exercise the real path instead of the simulated one. pre_get_col_charset lets a test force option_value to utf8mb3, so an update check whose payload carries an emoji gets rejected by wpdb for real and the warning fires, no schema surgery needed. Happy to add that here if you'd like it in.

Add a test that drives the actual failure path from the ticket rather
than a simulated one: a four byte character in the update payload and a
storage column that can't hold it. It uses `pre_get_col_charset` to make
wpdb treat the column as utf8mb3, so `wpdb::update()` strips the value in
`process_fields()`, sets `$wpdb->last_error`, and returns false before
running a query. The assertion on wpdb's own error message confirms the
warning reacts to a genuine rejection, no schema change required.
@siliconforks

Copy link
Copy Markdown

The check isn't catching a leftover from something earlier.

One issue I see with the last_error check is that it assumes that transients are being stored in the database, which may not necessarily be the case. This means it might be detecting a database error occurring previously somewhere else that has nothing to do with updates.

To demonstrate this, perform the following steps:

  1. Install an object cache (e.g., using memcached)

  2. Install and activate this simple plugin:

    <?php
    
    /*
    Plugin Name: Admin Init Priority 9
    */
    
    if ( ! defined( 'ABSPATH' ) ) {
    	exit;
    }
    
    add_action( 'admin_init', static function () {
    	global $wpdb;
    
    	$sql = 'SELECT COUNT(*) FROM nonexistent_table';
    	$count = $wpdb->get_var($sql);
    	if ( $count > 0 ) {
    		wp_die( 'It looks like this table actually exists' );
    	}
    }, 9 );

    This plugin is performing a database query that generates an error. It doesn't have anything to do with updates, but it runs in the admin_init action with priority 9, meaning it will run just before the check for updates in the admin section.

  3. Apply this PR. Make sure it's the latest version with the last_error check.

  4. Using WP-CLI, clear the update_plugins transient:

    wp transient delete --network update_plugins
    

    This will force an update check to occur the next time you visit an admin page.

  5. Now visit any page in the admin section. This should generate a warning message: wp_update_plugins(): The result of the plugin update check could not be stored: Table 'wordpress.nonexistent_table' doesn't exist

    It's detecting an error, but the error doesn't really have anything to do with plugin updates.

I can think of a couple of ways that might be used to avoid this:

  1. Use the wp_using_ext_object_cache() function before checking last_error:
    	if ( ! wp_using_ext_object_cache() && ! empty( $GLOBALS['wpdb']->last_error ) ) {
    		wp_trigger_error( ... );
    	}
  2. Set last_error to the empty string first, before calling set_site_transient():
    	$GLOBALS['wpdb']->last_error = '';
    	set_site_transient( ... );
    	if ( ! empty( $GLOBALS['wpdb']->last_error ) ) {
    		wp_trigger_error( ... );
    	}

$wpdb->last_error only reflects update-transient storage when the
transient is written to the database. With an external object cache,
set_site_transient() never touches the database, so last_error could
carry an unrelated error from an earlier query in the request and be
misattributed to the update check. Guard the check with
wp_using_ext_object_cache() so it only runs when the write actually goes
to the database.
@gunjanjaswal

Copy link
Copy Markdown
Author

Good catch, you're right. $wpdb->last_error only reflects the transient write when the transient actually goes to the database. With an external object cache, set_site_transient() never runs a query, so last_error can be left over from something earlier in the request, exactly like your admin_init priority-9 example, and get misattributed to the update check.

Pushed fa6d86f to guard all three checks with wp_using_ext_object_cache(), so the last_error check only runs when the write is database-backed. When an object cache is in use there's nothing for last_error to tell us about transient storage anyway, so skipping it there loses nothing. The existing tests already skip themselves when an object cache is active, so they still cover the database path.

@slavicd

slavicd commented Oct 5, 2026

Copy link
Copy Markdown

This does not look like a good solution. set_site_transient() needs a mechanism to signal a failure, such as an exception.

@siliconforks

Copy link
Copy Markdown

set_site_transient() needs a mechanism to signal a failure, such as an exception.

Yes, currently set_site_transient() has no way of signaling an error, because it calls update_option() and that has no way of signaling an error. (That is issue #41099.)

@gunjanjaswal

Copy link
Copy Markdown
Author

That's fair, and it's the real gap underneath this: set_site_transient() → update_option() has no way to signal a write failure (#41099), so the last_error check here is explicitly a workaround for that, scoped to the three update-check paths and guarded to only fire when transients are actually DB-backed.

I framed this as a narrow, interim fix for #64550 — so the update check stops reporting success after a write that didn't land — rather than a general solution to transient error-signaling. The root-cause storage fix is #13282; this was meant to sit alongside it as detection.

That said, if the preference is to hold this until update_option()/set_site_transient() can report failures properly (#41099), that's completely reasonable — I'm happy to pare it back or close it in favor of that direction. Whatever you and the maintainers think is the right path here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants