5

Update:

Per suggestion from the accepted answer below, I tested reading a negative number:

$negativeInt = -1;
Cache::write('my-test-count', $negativeInt, 'short');
$readVal = Cache::read('my-test-count', 'short');
debug($readVal);
exit;

The unseralize error reproduces consistently when trying to read any negative number. It's now an accepted bug, which I assume will be resolved by 2.8.1


Original Question:

I keep getting this error, but can't figure out why or even how to further troubleshoot.

The line that throws the error should only get hit if Cache::read() returns false. But, that line would throw an unserialize error itself if I didn't put the @ before it.

Question: How can I reliably use Redis for counts without getting unserialize Notices on a regular basis? If the data in the key is "bad", how can I know that without getting the Notice just from doing a ::read. I've tried making sure my data is (int) (see below), but that doesn't seem to help.

Notice (8): unserialize(): Error at offset 0 of 2 bytes [APP/Vendor/pear-pear.cakephp.org/CakePHP/Cake/Cache/Engine/RedisEngine.php, line 136]

Upon inspection of the error:

> unserialize - [internal], line ??
> RedisEngine::read() - APP/Vendor/pear-pear.cakephp.org/CakePHP/Cake/Cache/Engine/RedisEngine.php, line 136
> Cache::read() - APP/Vendor/pear-pear.cakephp.org/CakePHP/Cake/Cache/Cache.php, line 358
> Cache::remember() - APP/Vendor/pear-pear.cakephp.org/CakePHP/Cake/Cache/Cache.php, line 567
> Item::getCount() - APP/Model/Item.php, line 812

It appears to be coming from this function:

public function getCount($id) {
    $model = $this;

    // here is where the Cache::read() and debug are in the update below

    return Cache::remember('item' . $id. '_count', function() use ($model, $id) {
        $count = $model->find('count', array(
            'conditions' => array(
                $model->alias . '.status' => 1,
                $model->alias . '.id' => $id
            )
        ));
        return ($count === false) ? 0 : (int)$count;
    }, 'my_counts'); // THIS IS LINE 812
}

public function decrementCount($id, $offset = 1) {
    if(empty($id)) return false;
    $count = @Cache::read('item' . $id . '_count', 'my_counts');
    if($count === false) {
        $this->getCount($id);
    } else {
        Cache::decrement('item' . $id . '_count', $offset, 'my_counts');
    }
}

public function incrementCount($id, $offset = 1) {
    if(empty($id)) return false;
    $count = @Cache::read('item' . $id. '_count', 'my_counts');
    if($count === false) {
        $this->getCount($id);
    } else {
        Cache::increment('item' . $id. '_count', $offset, 'my_counts');
    }
}

UPDATE:

This function gets run in a loop (through 1-20 items). When I add the following before the Cache::remember(...:

$toReturn = Cache::read('item' . $id. '_count', 'my_counts');
debug($toReturn);

It gives this:

  1. debug: (int) 0
  2. debug: (int) 0
  3. Notice (8): unserialize(): Error at... (from the Cache::read)
  4. debug: false
  5. Notice (8): unserialize(): Error at... (from the Cache::remember
  6. debug: (int) 0
  7. Notice (8): unserialize(): Error at... (from the Cache::read)
  8. debug: false
  9. Notice (8): unserialize(): Error at... (from the Cache::remember
  10. 0
  11. 3
  12. 1
  13. 1 ...
Dave
  • 27,341
  • 18
  • 106
  • 177
  • _It only happens when after that specific cache key gets deleted_ - How is it deleted? From the stack seems that `item' . $id. '_count` [exists](https://github.com/cakephp/cakephp/blob/667e2be2d27ad210769f919fefdb35e45ab354d5/lib/Cake/Cache/Cache.php#L568) – Federkun Jan 22 '16 at 00:09
  • @Federico - I updated my question. Apprently it happens after decrementing the count. The error doesn't show on the page that calls the decrement, but the next time it needs to get the count, it shows the error. – Dave Jan 22 '16 at 00:40
  • I have a teory, but i'm not sure. Can you just change `($count === false) ? 0 : $count;` to `($count === false) ? 0 : (int) $count;`? – Federkun Jan 22 '16 at 00:50
  • @Federico - no luck. Same issue :( – Dave Jan 22 '16 at 01:47
  • Can you look at the value of the key in Redis? The error is an unserialize error, which means the data in the key is bad. The Cache::remember function probably the first time you're reading the key so that's where it's having an error, but if you try a Cache::read() instead it should throw the error there. – dragmosh Jan 22 '16 at 07:23
  • @dragmosh - I've updated my question with the Cache::read() - it does throw the error there too. – Dave Jan 22 '16 at 16:40

3 Answers3

2

It seems to be a core bug. I might be able to answer to why this issue occurs.

I am guessing that this issue is caused by int(-1). When writing data, RedisEngine doesn't serialize the data if it is an integer. Therefore, int(-1) will be saved without calling serialize().

public function write($key, $value, $duration) {
    if (!is_int($value)) {
        $value = serialize($value);
    }
    ...
}

But when reading data, the implementation seems asymmetric. I don't know Redis well, but I am guessing that Redis stores int(-1) as string(-1). As ctype_digit("-1") returns false, string(-1) would be unserialized wrongly.

public function read($key) {
    $value = $this->_Redis->get($key);
    if (ctype_digit($value)) { // ctype_digit("-1") === false
        $value = (int)$value;
    }
    if ($value !== false && is_string($value)) { // is_string("-1") === true
        $value = unserialize($value);
    }
    ...
}

Thus, you will see "Notice (8): unserialize(): Error at offset 0 of 2 bytes". The size of string(-1) is 2 bytes.

What about opening an issue on github, if you could reproduce that writing int(-1) and reading it throws a notice.

Aside from that, a workaround would be stop using Cache::decrement(). On race conditions, the method will store int(-1). You may use Cache::write() instead if it is not an important data. For example:

$count = Cache::read('item' . $id. '_count', 'my_counts');
if ($count === false || 0 > $count - $offset) {
    $this->getCount($id);
} else {
    Cache::write('item' . $id. '_count', $count - $offset, 'my_counts');
}

But if it is an important data, then you may need to implement exclusive lock/unlock.

Thank you for reading this to the end and sorry if I am wrong.

Kurita Takashi
  • 361
  • 1
  • 7
  • It is now an accepted bug, and has been reproduced consistently (as seen in my about-to-be-updated question). Thanks for sending me down that path, and just testing if -1 could be read. – Dave Feb 27 '16 at 05:02
  • Thanks. I am happy that I could help you. – Kurita Takashi Feb 27 '16 at 05:46
0

Solution:

return ($count === false) ? 0 : (int)$count;

Long version:

You shouldn't increment/decrement serialized value.

Cache::remember does something like this:

$key = 'key';
$value = 10;
$serialized = serialize($value); // $serialized = 'i:10;'
$redis->set($key, $serialized);

Cache::read does something like this:

$key = 'key';
$serialized = $redis->get($key); // $serialized = 'i:10;'
$value = unserialize($serialized); // $value = (int) 10
return $value;

But Cache::decrement does something like this:

$key = 'key';
$count = 1;
$redis->decrby($key, $count); // Error, decrby expects plain value, "10", not "i:10;" ( http://redis.io/commands/decrby )

Also, if you decrement successfully, then you will have plain value in redis, so result will be:

$key = 'key';
$serialized = $redis->get($key); // $serialized = '10'
$value = unserialize($serialized); // error, cannot unserialize '10'

EDIT: After checking source: https://github.com/cakephp/cakephp/blob/master/src/Cache/Engine/RedisEngine.php

I see that Cake does checks:

    if (!is_int($value)) {
        $value = serialize($value);
    }

So all you need to do to avoid serialization is to make sure that you are storing (int).

my-nick
  • 612
  • 1
  • 5
  • 11
  • Unless I'm missing something, this looks like it's suggesting the same solution as the one in the comments that didn't work. – Dave Feb 14 '16 at 02:56
  • Well, we haven't seen all your code. Maybe for example you are setting $offset as string not int. But you have "Long version" explaining what is happening. – my-nick Feb 14 '16 at 20:08
-2

Just remove third parameter, it should be work

return Cache::remember('item' . $id. '_count', function() use ($model, $id) {
    $count = $model->find('count', array(
        'conditions' => array(
            $model->alias . '.status' => 1,
            $model->alias . '.id' => $id
        )
    ));
    return ($count === false) ? 0 : (int)$count;
});
Haresh Vidja
  • 7,750
  • 3
  • 22
  • 40
  • The 3rd parameter is what tells it which of my cache settings to use and is needed. – Dave Feb 26 '16 at 14:19