Make WordPress Core

Opened 9 years ago

Last modified 44 hours ago

#32848 accepted defect (bug)

Null values for Update Options does not reset in $all_options

Reported by: mikengarrett's profile MikeNGarrett Owned by: pbearne's profile pbearne
Milestone: 6.8 Priority: normal
Severity: normal Version: 4.2.2
Component: Cache API Keywords: has-patch has-unit-tests
Focuses: Cc:

Description

This is a fun, extremely niche case.

We were using the Settings API to save out options for a plugin when we came across a really frustrating scenario. Half the time our setting was returning values that were not accurate to the setting that was stored in the database and in cache.

Come to find, the value being returned from wp_load_alloptions was incorrect while every other instance was correct. In our case, these values were in the database, memcache, and the single value in wp-object-cache.

On line 319 of option.php, we're using isset to check the value in the array. This returns false if the value is NULL. This leads to all kinds of sadness.

I would propose we instead use array_key_exists here to ensure we're getting true if the key is present in the array at all.

Attachments (2)

32848.diff (510 bytes) - added by MikeNGarrett 9 years ago.
Patch #1
32848.2.diff (5.1 KB) - added by MikeNGarrett 9 years ago.
Patch 2 - replace all isset in option.php

Download all attachments as: .zip

Change History (22)

@MikeNGarrett
9 years ago

Patch #1

This ticket was mentioned in Slack in #core by mikengarrett. View the logs.


9 years ago

#2 @MikeNGarrett
9 years ago

Co-contributor: @tndavide

Version 0, edited 9 years ago by MikeNGarrett (next)

#3 @MikeNGarrett
9 years ago

  • Keywords has-patch needs-unit-tests added

I ran back through and realized there were quite a few places here that are using isset for assessing array keys existence. Here's a patch to switch all isset to array_key_exists. Next step, let's create some unit tests.

@MikeNGarrett
9 years ago

Patch 2 - replace all isset in option.php

#6 @MikeNGarrett
5 years ago

  • Keywords bulk-reopened added

This is still present, but it's a very narrow and uncommon case. I would be ok closing this.

https://core.trac.wordpress.org/browser/tags/5.1.1/src/wp-includes/option.php#L391

#7 @pbearne
6 months ago

  • Owner set to pbearne
  • Status changed from new to accepted

isset is faster than array_key_exist https://stackoverflow.com/questions/3210935/whats-the-difference-between-isset-and-array-key-exists

we could do both which is only a little slower

isset()
array_key_exists()

Thoughts

This ticket was mentioned in Slack in #core-performance by pbearne. View the logs.


6 months ago

#9 @MikeNGarrett
6 months ago

It's been a minute, so let me wrap my head around this.

The original issue is that in the case of $options = array('test' => null); that isset($options['test']) returns false, leading to differences in the structure of the options array.

I just checked out 6.5.2 (https://core.trac.wordpress.org/browser/tags/6.5.2/src/wp-includes/option.php) and it looks like this issue still exists.

I think adding a secondary check for array_key_exists() after isset() would work wonderfully without slowing down execution in most circumstances.

This ticket was mentioned in PR #6766 on WordPress/wordpress-develop by @pbearne.


4 months ago
#10

The existing condition logic for option checks only uses the 'isset' function. This commit adds additional checks using 'array_key_exists' alongside 'isset'. This helps to handle edge cases more accurately where the array key exists but its value is null.

#11 @pbearne
4 months ago

  • Keywords has-unit-tests added; needs-unit-tests bulk-reopened removed

I have refreshed the patch

#12 @pbearne
3 months ago

  • Milestone set to 6.7

This ticket was mentioned in Slack in #core-performance by mukeshpanchal27. View the logs.


3 months ago

#14 @pbearne
3 months ago

TODO: Add test for null value

#15 @pbearne
3 months ago

I looked at the code, and I could not find a way to check null in the functions, but I was able to confirm that the code change worked with this test.

<?php
/**
 * @ticket 32848
 *
 * Test that $options = array('test' => null); works.
 */
public function test_for_null() {
        $option = 'foo';
        $this->assertTrue( add_option( $option, null, '',true  ) );

        $alloptions = wp_load_alloptions();
        $this->assertFalse( isset( $alloptions[ $option ] ) );
        $this->assertTrue( isset( $alloptions[ $option ] ) || array_key_exists( $option, $alloptions ) );
  }

I can level this test in but it doesn't test function rather a fragment of code so it doesn't feel right

Paul

Last edited 3 months ago by pbearne (previous) (diff)

#16 @peterwilsoncc
3 months ago

Related #22192.

This appears to be due to how add_option() caches data compared to how it is stored in the database. When the data is originally cached it uses the data type as passed to the function. When stored in the database, the data is type cast to a string.

Without a persistent cache, subsequent requests to load alloptions will result in isset() === true as the value will be returned as an empty string. To demonstrate via WP CLI:

vagrant@wp-dev:/vagrant$ wp shell
wp> add_option( '6766-testing', null );
=> 
bool(true)
wp> wp_load_alloptions()['6766-testing']
=> 
NULL
wp> isset( wp_load_alloptions()['6766-testing'] )
=> 
bool(false)
wp> exit


vagrant@wp-dev:/vagrant$ wp shell
wp> wp_load_alloptions()['6766-testing']
=> 
string(0) ""
wp> isset( wp_load_alloptions()['6766-testing'] )
=> 
bool(true)
wp> exit

I suspect the best option might be to ensure that options are cached in the same form that they are stored (ie, as a string). This would ensure that the behavior of get_option() doesn't randomly change depending on whether the cache is populated or unpopulated.

Although some work along these lines in #22192 proved to be risky and was ultimately reverted.

(Mike and Paul: I know you're probably aware of the cause but best to spell it out.)

#17 @MikeNGarrett
3 months ago

I've opened and closed a business, changed cities, bought a house, started a family, and gone through a pandemic since I opened this ticket.

...but for some reason I still remember the details of this ticket.

Yes, the issue is that null values in an array are cast to empty strings when being stored in the database. In cache, they are correctly stored as null.

This ticket was mentioned in Slack in #core-performance by mukeshpanchal27. View the logs.


3 months ago

This ticket was mentioned in PR #7133 on WordPress/wordpress-develop by @peterwilsoncc.


8 weeks ago
#19

This casts the cached value of options to a string in update_option(), update_network_option(), add_option() and add_network_option().

Currently the cached value of an option can be in either of the original type or a string, depending on whether the cache was primed after a database query in get_(network)_option() (always a string) or primed while updating or adding an option (type as passed to the function).

The effect of this is that the result of get_(network)_option() can be inconsistent depending on whether the cache was primed or required a database query.

This change ensures that the options always use the same type (a string) regardless of how the cache is primed.

Risks

Yes.

On hosting providers that prevent the options cache/all options cache from being flushed the type would consistently be as set by the code while adding or updating the value. Following this change, that will no longer be the case.

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

#20 @flixos90
7 weeks ago

  • Focuses performance removed

@peterwilsoncc I left some feedback on your draft PR https://github.com/WordPress/wordpress-develop/pull/7133.

While the ideal solution is possibly along the lines of what you're saying, I don't think this is realistic to implement given all the BC breakage side effects it would have (IMO even more severe than the problems we found in #22192).

I would prefer if we narrowed the solution to this ticket to address the more specific problem with null values and wp_load_alloptions() - which doesn't address the underlying problem, but I think is a more reasonable way to fix what this ticket is about.

Aside, I'll remove the performance focus, since this ticket is not about performance, but about a general bug in Core.

@peterwilsoncc commented on PR #7133:


7 weeks ago
#21

I am concerned about this change, mostly because it changes the return type of the option retrieval functions, which I think is notable backward compatibility breakage. The few get_option() instances you have to update in this PR alone indicate that.

I've pushed some tests to demonstrate the issue I'm trying to resolve. The return type for the options getters is currently inconsistent depending on whether the cache is warm or cold. See the action run after pushing the test changes only.

Anything that uses get_option() === for non-string values without typecasting is probably buggy unless there is type casting on the option_{$option} filter.

The tests are showing that that's still the case in some places so I will look at those and come back to the rest of your feedback and suggestions.

#22 @peterwilsoncc
44 hours ago

  • Milestone changed from 6.7 to 6.8

I'm moving this off the release as it's unlikely a fix will be committed prior to the final 6.7 beta in just under 24 hours.

Note: See TracTickets for help on using tickets.