Following some strange occurrences on a site where we are using memcache for semaphore we have been investigating the memcache lock_acquire and believe that it may be possible for two processes to acquire a lock on the same lock key.

This appears to be a known issue with memcache add - the following page on php.net (http://www.php.net/manual/en/memcache.add.php#96278) indicates that race conditions can occur under heavy load.

Bearing in mind the suggested fix in that thread I have created a script to try and reproduce the issue in a controlled manner:

<?php

require_once './includes/bootstrap.inc';
drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);

global $locks;

$name = 'My-Lock';
$timeout = 600;

// Ensure that the timeout is at least 1 sec. This is a limitation
// imposed by memcached.
$timeout = (int) max($timeout, 1);

if (dmemcache_add($name, _lock_id(), $timeout, 'semaphore')) {
  $locks[$name] = _lock_id();

  // check key is correct - to ensure thread safety
  if ($locks[$name] == dmemcache_get($name, 'semaphore')) {
    drupal_set_header($_SERVER['SERVER_PROTOCOL'] . ' 201 Memcache safe add');
    print 'Memcache set safely';
  }
  else {
    drupal_set_header($_SERVER['SERVER_PROTOCOL'] . ' 599 Memcache unsafe add');
    print 'Memcache set unsafely';
  }
}
else {
  drupal_set_header($_SERVER['SERVER_PROTOCOL'] . ' 200 Memcache no add');
  print 'Didnt add to memcache';
}
?>

The idea behind setting different response headers was to be able to use apache bench to send a high volume of concurrent requests and use its built in response header reporting to show any errors:

We then used:

ab -n 100 -c 2 -v 3 http://www.example.com/lock_test.php

And got:

Document Path: /lock_test.php
Document Length: 19 bytes

Concurrency Level: 2
Time taken for tests: 18.267015 seconds
Complete requests: 100
Failed requests: 1
(Connect: 0, Length: 1, Exceptions: 0)
Write errors: 0
Non-2xx responses: 1
Total transferred: 34118 bytes
HTML transferred: 1902 bytes
Requests per second: 5.47 [#/sec] (mean)
Time per request: 365.340 [ms] (mean)
Time per request: 182.670 [ms] (mean, across all concurrent requests)
Transfer rate: 1.81 [Kbytes/sec] received

The one "Non-2xx" response was confirmed in the output as being the 599 header for an unsafe memcache add (i.e. a memcache key set with the correct name, but not the lock_id of the request that is currently executing (and so in theory two processes would have gained a lock)

Comments

tsphethean’s picture

StatusFileSize
new862 bytes

Based on the above findings, and the suggested workaround on php.net, the attached patch attempts to verfiy that the value of the successful memache_add is equal to the lock_id() for the current request.

It has the downside of incurring an additional memcache_get() for every memcache_add() on the semaphore bin.

I'd be grateful for any feedback on this - both in terms of the original test script, and the proposed solution. Is it a good test? Is the workaround valid?

iainp999’s picture

StatusFileSize
new1.12 KB

Added alternative patch that only performs check on successful 'memcache_add'.

mdupont’s picture

I confirm the issue. The current version of lock_acquire call _lock_id() that only has a static variable cache. So if a call to lock_acquire comes from another request, it won't see a lock is already there and it will update the lock id. We need to check if the lock exists first.

Consider the following:
- you launch update.php on your site
- at the end it sets the menu_rebuild_needed variable
- user A requests a page on the site. Since menu_rebuild_needed is set, a menu_rebuild() is launched.
- menu_rebuild() does a check against lock_acquire() to check if another menu_rebuild() is running. lock_acquire() returns TRUE so menu_rebuild() runs.
- user B also requests a page on the site. The menu_rebuild() of user A is not yet finished so menu_rebuild_needed is still there. So menu_rebuild() launch. Since it's in another PHP process, lock_acquire() will not see there is already a lock set, so it will update the lock id and return TRUE. So a second menu_rebuild() is launched in parallel of the first.
- Rince, repeat and crash your site if ever you have large menu that make menu_rebuild take long and if you have lots of traffic

iainp999’s picture

@mdupont I'm not really sure that is an issue since the important value is the lock name, not the ID. The ID is used to associate locks with a particular request.

The static lock cache simply holds locks that are held within the current request.

In your example, user A will hold the lock by name, and therefore user B will not acquire the lock.

mdupont’s picture

Well, I am precisely encountering the above issue, but after further investigation it may not come from Memcache directlly.

tsphethean’s picture

@mdupont - best practice would be to either put your site into maintainance mode when you want to run update.php, or use drush to run drush updatedb and avoid the problem. Even then, it's best to limit user traffic to the site when installing updates.

The way the lock_id is used is as the value of the lock key in memcache, so I think your scenario would only be a problem if memcache-lock.inc really is thread unsafe and you have a stampede on your menu rebuilds, then there is a chance that the value of the memcache key returned wouldn't match the lock Id of the requesting process when the rebuild starts.

Do you receive the same errors we've reported using the test script above with your memcache configuration?

tsphethean’s picture

Following performance testing in a prod-like environment revealing that the above patch had no significant impact on performance of webservers or memcache, we have released this patch into production for a very large Drupal 6 installation (circa 1 million page views per day + high volume authenticated users).

24 hours on, we have seen no issues with performance on the webservers or memcache, and no ocurrances so far of the issues which led us to believe that memcache wasnt thread safe.

Fingers crossed this has fixed a major issue for us, hopefully it will do the same for others. Would be interesting to hear of any other experiences.

tsphethean’s picture

Status: Active » Needs review
damien tournoud’s picture

Tom, have you tested if this is related to the replication set up that you have? Does it happen when only using a single Memcache server?

yonailo’s picture

Looking at patch #2, shouldn't we look at $result variable before renewing the lock too ? I mean, if $result != $lock_id then we should not execute dmemcache_set, because it is not our lock. This could happen if the lock expired and somebody else grabbed it before us.

I talk about this block of code:

  elseif ($result = dmemcache_get($name, 'semaphore') && isset($locks[$name]) && $locks[$name] == $lock_id) {
    // Only renew the lock if we already set it and it has not expired.
    dmemcache_set($name, $lock_id, $timeout, 'semaphore');
  }
yonailo’s picture

StatusFileSize
new1.18 KB

Proposed patch here:

japerry’s picture

Status: Needs review » Closed (outdated)

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.