I know this issue has a long and messy history, but I really believe that it can be fixed in a robust and sensible way. Since this involves some nasty concurrency issues I'll add a bit of background here. Many of you will know this background already, but its probably helpful to gather in one place.

1. The problem

Drupal uses sequences to get table id, to be used like this:

  $id = db_next_id('{example}_xid');
  db_query("INSERT INTO {example} (xid, data) VALUES (%d, '%s')", $id, $data);

The id returned by db_next_id() must be unique. But Drupal is a concurrent system, so this must work even if two processes try to run it at the same time.

The current MySQL implementation uses table locks, like this

  db_query('LOCK TABLES {sequences} WRITE');
  $id = db_result(db_query("SELECT id FROM {sequences} WHERE name = '%s'", $name)) + 1;
  db_query("REPLACE INTO {sequences} VALUES ('%s', %d)", $name, $id);
  db_query('UNLOCK TABLES');
  return $id;

The LOCK/UNLOCK prevents a concurrent process slipping in between the SELECT and REPLACE: without this, a concurrent process may end up with the same ID.

But some hosts do not allow LOCK TABLES. People don't like having to change hosts just because Drupal tells them to, and I wonder how many people have used Another CMS because it doesn't work on their hosting company. Or some posters report just taking out troublesome LOCK TABLES command and say they've not seen any problems so far. Hmm.

In passing, there is another potential problem with the current method: it is not robust against intermittent database query errors (these can happen - its usually on another machine after all). Consider this:

  1. The SELECT query fails due to an intermittent failure.
  2. We don't notice.
  3. $id gets set to NULL + 1 = 1. Oops.
  4. REPLACE query overwrites sensible sequence number with 1. Oh dear.
  5. db_next_id() returns 1
  6. Caller attempts to insert record with id 1, either failing or overwriting records starting from 1.
  7. Subsequent records overwrite 2, 3, ...

To fix this properly we need to allow db_next_id() to tell the caller when it failed, so we'll do that too.

2. Why bother to fix it?

I believe this should be fixed, because

  • LOCK TABLES is a barrier for many potential Drupal users (see end for discussions)
  • There are some bad solutions about (e.g. delete the LOCK statements). If they break, Drupal will get the blame.
  • There is a clean solution that is concurency-safe without locks (its just a bit conceptually tricky).
  • The solution is more efficient, using only one UPDATE (and a mysql_insert_id()) in normal operation in place of LOCK/SELECT/REPLACE/UNLOCK sequence.
  • It is a drop-in replacement for db_next_id(), with no need to change any other code or tables
  • It does not rely on MySQL 4 features (e.g. SELECT FOR UPDATE).
  • We can fix a potential intermittent database query failure bug in the process.

I'm going to spend a little time going over the approach. Sorry for the length, but I hope it will be helpful for some readers who may want to use the patch in the meantime.

3. The approach

The approach is based on that suggested by smoogle in http://drupal.org/node/34455. OK I thought of it too but smoogle got there first :-)

As a starting point, let's take a look at the MySQL manual's suggested way to implement sequences in http://dev.mysql.com/doc/refman/4.1/en/information-functions.html:

If expr is given as an argument to LAST_INSERT_ID(), the value of the argument is returned by the function and is remembered as the next value to be returned by LAST_INSERT_ID(). This can be used to simulate sequences:

1. Create a table to hold the sequence counter and initialize it:

mysql> CREATE TABLE sequence (id INT NOT NULL);
mysql> INSERT INTO sequence VALUES (0);

2. Use the table to generate sequence numbers like this:

mysql> UPDATE sequence SET id=LAST_INSERT_ID(id+1);
mysql> SELECT LAST_INSERT_ID();

The UPDATE statement increments the sequence counter and causes the next call to LAST_INSERT_ID() to return the updated value. The SELECT statement retrieves that value. The mysql_insert_id() C API function can also be used to get the value.

So LAST_INSERT_ID() is magic, and "pretends" to be an AUTO_INCREMENT column even if your table doesn't have one.

Modifying the MySQL method slightly for Drupal sequences, the core we need is

  db_query("UPDATE {sequences} SET id=LAST_INSERT_ID(id+1) WHERE name = '%s'", $name);
  return mysql_insert_id();

The update is an atomic operation, so there is no danger that a concurrent process will interfere with it.

This is not quite all, since Drupal only creates sequences as needed. An easy way to do this is to prepend the update with an INSERT IGNORE:
db_query("INSERT IGNORE INTO {sequences} VALUES ('%s', %d)", $name, 0);
If the sequence doesn't exist, it creates it with value zero. If it exists already, it does nothing, and the IGNORE nicely suppresses the error that we would have got.

We can actually do this a little more efficiently, since we only have to create the sequence the first time a sequence is ever used. We therefore do the following:

  1. Attempt the update first. If that works, we're done.
  2. If the update failed, use INSERT to create the table and try again.

To allow for a concurrent process doing this at the same time, we retain INSERT IGNORE since the other process may actually create the sequence for us if it gets between steps 1 and 2 above.

So the almost-final solution (similar to http://drupal.org/node/34455) is

 function db_next_id($name) {
  $name = db_prefix_tables($name);
  db_query("UPDATE IGNORE {sequences} SET id=LAST_INSERT_ID(id+1) WHERE name = '%s'", $name);
  if (!db_affected_rows()) {
    db_query("INSERT IGNORE INTO {sequences} VALUES ('%s', %d)", $name, 0);
    db_query("UPDATE {sequences} SET id=LAST_INSERT_ID(id+1) WHERE name = '%s'", $name);
  }
  return mysql_insert_id();
}

Note that intermittent database errors should never cause id to be reset, since it will either be incremented or unchanged (the INSERT can never overwrite an existing row). However, we should check the second UPDATE to make sure it worked, since if not we should return an error code to the user

    $ok = db_query("UPDATE {sequences} SET id=LAST_INSERT_ID(id+1) WHERE name = '%s'", $name);
    if (!$ok) {
      return false;
    }

This adds a new functionality to db_next_id, i.e. that it returns FALSE on failure, so can be used as follows:

    $id = db_next_id('{example}_xid');
    if (!$id) {
       // Failed to get an ID, so take a suitable error action
       return false;
    }
    // Got the id OK, so insert the record
    db_query("INSERT INTO {example} (xid, data) VALUES (%d, '%s')", $id, $data);

This is almost backward compatible with current behaviour: if you don't check for failure and plough on regardless, you just attempt to insert with id=0 (instead of id=1 on failure, and subsequently 2, 3, ...).

4. The patch

I've attached a patch for database.mysql.inc for CVS HEAD that incorporates this solution. I have tested this on a new CVS install as of 5 Aug 2006, creating the first "story" to ensure the branch works. If this is OK to incorporate, a directly similar solution should also work for database.mysqli.inc, and also for Drupal 4.7, as far as I can tell.

5. Related posts & discussions

This has a long history of both interesting and dangerous suggested solutions. In case anyone is interested, here are a few:

I hope this is useful,

Mark.

CommentFileSizeAuthor
database.mysql.inc_3.patch2.9 KBplumbley

Comments

killes@www.drop.org’s picture

Apart from a few minor code style issues this looks like an excellent patch to me.

moshe weitzman’s picture

Wow, thats a terrific writeup. You should stop coding and just write documentation :)

The patch works as advertised. I tried with a new sequence and with an existing sequence. The LOCK TABLES error is very frequently mentioned in the forums, and we would do well to get rid of it.

I uppercased FALSE and removed some non standard 'end if' comments.

Please do include database.mysqli.inc in this patch. and please don't bother working on 4.7 branch since this change is not worthy of backport IMO.

chx’s picture

I took then liberty to write mysqli part (warning, there seems to be no mysqli_insert_id for all php5) and merged the patch into http://drupal.org/node/55516 -- what's good in removing a pair of locks if another remains?

plumbley’s picture

Glad to help! I'm working (bit by bit) on my first Drupal site, so I'll try to contribute a few bits back in the process...

Mark.

dries’s picture

chx: is this a duplicate of http://drupal.org/node/55516 then? If so, mark it as such?

Anonymous’s picture

i'm not entirely familiar with the problem this is solving, so this is may be an improvement and worthy of going in regardless.

however, i dont' think this is not going to be safe on high traffic sites.

lets say two processes 'a' and 'b' hit db_next_id() at almost exactly the same time (as will happen on a busy site).

if process a wins the race to tell mysql to update the sequence table, what's to stop process b telling mysql to do the same thing before process a is runs mysql_next_id().

db_affected_rows() calls mysql_affected_rows(), so its possible for the window to be big enough for the next process to get its update in before the id is returned by mysql_next_id().

here's what could happen in code, in case that wasn't clear:

$link = mysql_connect('localhost', 'inserttest', 'testing');

mysql_select_db('inserttest');

/**
 * assuming this code has already been run
 * mysql_query('CREATE TABLE sequence (id INT NOT NULL)');
 * mysql_query('INSERT INTO sequence VALUES (0)');
 */

// process 'a'
mysql_query('UPDATE sequence SET id=LAST_INSERT_ID(id+1);');

// process 'b'
mysql_query('UPDATE sequence SET id=LAST_INSERT_ID(id+1);');

// process 'a'
print mysql_affected_rows($link) . "\n"; 

// process 'b'
mysql_affected_rows($link) . "\n"; 

// process 'a'
print mysql_insert_id() . "\n";

// process 'b'
print mysql_insert_id() . "\n";

that could cause some real nasty, subtle bugs, as its not necessarily going to cause stuff to blow up straight away.

am i missing something?

killes@www.drop.org’s picture

I don't think we will experience any bugs unless there is a bug in mysql itself. Also, we don't really depend on the order of IDs, we only depend on their uniqueness.

Anonymous’s picture

turns out i'm wrong - thanks killes and chx for pointing out that mysql_insert_id() operates per-connection, so it will do the right thing even if the sequence value is updated by a process using another connection before its called.

sorry for the noise.

Dandabass’s picture

Version: x.y.z » 4.7.3
Category: task » support
Priority: Normal » Critical
Status: Needs review » Closed (won't fix)

Hi,

This is my installation info: Drupal 4.7.3 on FreeBSD 4.11-STABLE, mysql Ver 12.22 Distrib 4.0.16, PHP 4.4.1. Share host provider not willing to change DB.

I applied the patch manually, this is how a portion of database.mysql.inc looks now:

lines 196 to 248

/**
 * Return a new unique ID in the given sequence.
 *
 * For compatibility reasons, Drupal does not use auto-numbered fields in its
 * database tables. Instead, this function is used to return a new unique ID
 * of the type requested. If necessary, a new sequence with the given name
 * will be created.
 *
 * Usage example:
 * @code
 *    // Attempt to get the next id
 *    $id = db_next_id('{example}_xid');
 *    if (!$id) {
 *       // Failed to get an ID, so take a suitable error action
 *       return false;
 *    }
 *    // Got the id OK, so insert the record
 *    db_query("INSERT INTO {example} (xid, data) VALUES (%d, '%s')", $id, $data);
 * @endcode
 *
 * Drupal convention is to prefix the table names using the '{'...'}' curly brackets,
 * although since the sequence table itself is prefixed this is not strictly necessary.
 *
 * @param $name
 *   Sequence name to use. Will be prefixed using db_prefix_tables()
 * @return
 *   The next unique ID, or FALSE if error
 */
function db_next_id($name) {
  // Prefix any curly-bracket "{table}" names
  $name = db_prefix_tables($name);
  // Assume initially that the sequence already exists.  Attempt to update it atomically.
  // LAST_INSERT_ID lets us get its value later. IGNORE suppresses update failures.
  db_query("UPDATE IGNORE {sequences} SET id=LAST_INSERT_ID(id+1) WHERE name = '%s'", $name);
  // Check whether that worked
  if (!db_affected_rows()) {
    // Updated failed, so (a) sequence doesn't exist yet, or (b) some database error
    // In either case, try to create a new sequence starting from zero
    // IGNORE suppresses insert error if e.g. a concurrent process got there first.
    db_query("INSERT IGNORE INTO {sequences} VALUES ('%s', %d)", $name, 0);
    // Sequence now exists (unless some database error occurred to prevent it)
    // Retry update, allowing for possible concurrent process. It should work this time.
    $ok = db_query("UPDATE {sequences} SET id=LAST_INSERT_ID(id+1) WHERE name = '%s'", $name);
    // Make sure it worked.
    if (!$ok) {
      // Update failed, so we failed to get an id. Tell the caller something's wrong.
      return false;
    } // end if [failed to get a sequence id]
   } // end if [sequence needs creating]
 
  // If we get here, the increment using LAST_INSERT_ID was successful, so return its value.
  return mysql_insert_id();
}

I still get the following error when changing my password on the Admin:

user warning: Access denied for user: 'theuser@localhost' to database 'thedb' query: LOCK TABLES cache WRITE in /home/site/folder/folder/includes/database.mysql.inc on line 120.

Even though my password does change, I prefer not to move on until this issue is solved. And after reading your article, i agree with what you exposed.

Any ideas will be great, thanks a lot for your time!!

chx’s picture

Version: 4.7.3 » x.y.z
Category: support » feature
Priority: Critical » Normal
Status: Closed (won't fix) » Closed (duplicate)

*sigh* alas, followups can not be edited/deleted yet or I would delete the last. Please ignore it. Has nothing to do with the issue.

Apply http://drupal.org/node/55516 instead as that removes every locking. If at all possible, do not comment there. Thanks much.

robertdouglass’s picture

tracking (I hope Drupal.org installs a bookmark feature!)

paddy_deburca’s picture

cache_set in bootstrap.inc (cache.inc in CVS) also puts a lock on the cache table during updates.

<?php
function cache_set($cid, $data, $expire = CACHE_PERMANENT, $headers = NULL) {
  db_lock_table('cache');
  db_query("UPDATE {cache} SET data = %b, created = %d, expire = %d, headers = '%s' WHERE cid = '%s'", $data, time(), $expire, $headers, $cid);
  if (!db_affected_rows()) {
    @db_query("INSERT INTO {cache} (cid, data, created, expire, headers) VALUES ('%s', %b, %d, %d, '%s')", $cid, $data, time(), $expire, $headers);
  }
  db_unlock_tables();
}
?>

I do not understand why the cache table is locked during update.

With the current lock table, and two concurrent processes (a and b) a possible scenario could be

  • a locks the cache table
  • b tries to lock the cache table, table already locked, b waits...
  • a updates the cache table, and unlocks it when finished
  • b receives the lock on the cache table
  • b updates the cache table, and unlocks it when finished

If a and b wish to update the same cache information b wins (as it is the last)

With a different cache_set function some-what based on the suggested logic

<?php
function cache_set($cid, $data, $expire = CACHE_PERMANENT, $headers = NULL) {
  db_query("UPDATE IGNORE {cache} SET data = %b, created = %d, expire = %d, headers = '%s' WHERE cid = '%s'", $data, time(), $expire, $headers, $cid);
  if (!db_affected_rows()) {
    @db_query("INSERT IGNORE INTO {cache} (cid, data, created, expire, headers) VALUES ('%s', %b, %d, %d, '%s')", $cid, $data, time(), $expire, $headers);
    $ok = db_query("UPDATE {cache} SET data = %b, created = %d, expire = %d, headers = '%s' WHERE cid = '%s'", $data, time(), $expire, $headers, $cid);
    if (!$ok) {
      return FALSE;
    }
  }
}
?>

The scenario would be

  • a creates/updates content in the cache table
  • b creates/updates content in the cache table

If a and b wish to update the same cache information a or b could win - but now there is a FALSE return code if a cache_set fails.

As I said previously I don't know why the cache table needs to be locked while updating.

Paddy.

plumbley’s picture

Removal of locks in general, including for cache_set(), is being dealt with in the patch at http://drupal.org/node/55516. (Caches do have different data integrity requirements than other records, but http://drupal.org/node/55516 nicely handles cache_set() it in one query for MySQL anyway). Mark.