29

So I've just realised that PHP is potentially running multiple requests simultaneously. The logs from last night seem to show that two requests came in, were processed in parallel; each triggered an import of data from another server; each attempted to insert a record into the database. One request failed when it tried to insert a record that the other thread had just inserted (the imported data comes with PKs; I'm not using incrementing IDs): SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '865020' for key 'PRIMARY' ....

  1. Have I diagnosed this issue correctly?
  2. How should I address this?

The following is some of the code. I've stripped out much of it (the logging, the creation of other entities beyond the Patient from the data), but the following should include the relevant snippets. Requests hit the import() method, which calls importOne() for each record to import, essentially. Note the save method in importOne(); that's an Eloquent method (using Laravel and Eloquent) that will generate the SQL to insert/update the record as appropriate.

public function import()
{
        $now = Carbon::now();
        // Get data from the other server in the time range from last import to current import
        $calls = $this->getCalls($this->getLastImport(), $now);
        // For each call to import, insert it into the DB (or update if it already exists)
        foreach ($calls as $call) {
            $this->importOne($call);
        }
        // Update the last import time to now so that the next import uses the correct range
        $this->setLastImport($now);
}

private function importOne($call)
{
    // Get the existing patient for the call, or create a new one
    $patient = Patient::where('id', '=', $call['PatientID'])->first();
    $isNewPatient = $patient === null;
    if ($isNewPatient) {
        $patient = new Patient(array('id' => $call['PatientID']));
    }
    // Set the fields
    $patient->given_name = $call['PatientGivenName'];
    $patient->family_name = $call['PatientFamilyName'];
    // Save; will insert/update appropriately
    $patient->save();
}

I'd guess that the solution would require a mutex around the entire import block? And if a request couldn't attain a mutex, it'd simply move on with the rest of the request. Thoughts?

EDIT: Just to note, this isn't a critical failure. The exception is caught and logged, and then the request is responded to as per usual. And the import succeeds on the other request, and then that request is responded to as per usual. The users are none-the-wiser; they don't even know about the import, and that isn't the main focus of the request coming in. So really, I could just leave this running as is, and aside from the occasional exception, nothing bad happens. But if there is a fix to prevent additional work being done/multiple requests being sent of to this other server unnecessarily, that could be worth pursuing.

EDIT2: Okay, I've taken a swing at implementing a locking mechanism with flock(). Thoughts? Would the following work? And how would I unit test this addition?

public function import()
{
    try {
        $fp = fopen('/tmp/lock.txt', 'w+');
        if (flock($fp, LOCK_EX)) {
            $now = Carbon::now();
            $calls = $this->getCalls($this->getLastImport(), $now);
            foreach ($calls as $call) {
                $this->importOne($call);
            }
            $this->setLastImport($now);
            flock($fp, LOCK_UN);
            // Log success.
        } else {
            // Could not acquire file lock. Log this.
        }
        fclose($fp);
    } catch (Exception $ex) {
        // Log failure.
    }
}

EDIT3: Thoughts on the following alternate implementation of the lock:

public function import()
{
    try {
        if ($this->lock()) {
            $now = Carbon::now();
            $calls = $this->getCalls($this->getLastImport(), $now);
            foreach ($calls as $call) {
                $this->importOne($call);
            }
            $this->setLastImport($now);
            $this->unlock();
            // Log success
        } else {
            // Could not acquire DB lock. Log this.
        }
    } catch (Exception $ex) {
        // Log failure
    }
}

/**
 * Get a DB lock, returns true if successful.
 *
 * @return boolean
 */
public function lock()
{
    return DB::SELECT("SELECT GET_LOCK('lock_name', 1) AS result")[0]->result === 1;
}

/**
 * Release a DB lock, returns true if successful.
 *
 * @return boolean
 */
public function unlock()
{
    return DB::select("SELECT RELEASE_LOCK('lock_name') AS result")[0]->result === 1;
}
Luke
  • 1,724
  • 1
  • 12
  • 17
  • 11
    I've not even read your question's content and have given you an up-vote. Thank God somebody is asking a real question and not just fix this typo, how to round a number, how to query a database! – Hanky Panky Jun 03 '15 at 06:51
  • Yes, concurrency is an issue. There are many ways to deal with this, depending on the situation. Locking, optimistic locking, mutex tokens, advisory locks... It all depends on the best solution for the given situation. While I'm also happy about a serious question, I'm not sure this is reasonably answerable in one answer... – deceze Jun 03 '15 at 07:15
  • Have you tried to build your own mutex/semaphore using memcache? It will help you if only one server is writing to database. – lvil Jun 03 '15 at 07:20
  • Wrote a mutex-like mechanism with flock()... does that seem reasonable? See the OP for the edit. Any idea how I would unit test this? How do I hit the import() method twice simultaneously...? – Luke Jun 05 '15 at 00:44
  • Just a heads up for anyone think of using a file lock; it creates fun headaches when you don't control the permissions of the file system on the server. /s – Luke Jun 09 '15 at 09:50
  • Hey Luke, what framework are you using for persistence? Do you know if it uses PDO? – RoyB Jun 12 '15 at 15:08
  • Laravel framework, namespace Illuminate\Database. Yes, it appears to be using PDO. – Luke Jun 15 '15 at 00:24
  • Any feedback on the new attempt at locking? See the OP for edit 3. – Luke Jun 16 '15 at 02:23

3 Answers3

5

Your example code would block the second request until the first is finished. You would need to use LOCK_NB option for flock() to return error immediately and not wait.

Yes you can use either locking or semaphores, either on filesystem level or directly in the database.

In your case when you need each import file to be processed only once, the best solution would be to have a SQL table with row for each import file. At the beginning of import, you insert the info that import is in progress, so other threads will know to not process it again. After import is finished, you mark it as such. (Then few hours later you can check the table to see if the import really finished.)

Also it is better to do such one-time long-lasting things like import on separate scripts and not while serving normal webpages to visitors. For example you can schedule a nightly cron job which would pick up the import file and process it.

Marki555
  • 6,434
  • 3
  • 37
  • 59
  • The import is an attempt to keep in sync with another DB, needs to be close to realtime; hence, not nightly and on every request :/ Thanks, I'll look into LOCK_NB! – Luke Jun 16 '15 at 01:54
  • Actually, you've prompted me to look into locking in the database. Does it seem feasible to use get_lock() and release_lock()? I'll post my implementation in a bit... – Luke Jun 16 '15 at 01:55
  • For near-realtime syncing (MySQL) I suggest free tool from percona toolset called `pt-table-sync` https://www.percona.com/doc/percona-toolkit/2.2/pt-table-sync.html . I use it from cron every 5 minutes (can be run even every 1 min) – Marki555 Jun 16 '15 at 08:12
  • Sounds like a useful tool! I'm not replicating data, though; getting data, restructuring and adding different fields and removing excess and so on for a small helper application. It's a prototype piece. I don't have any control over this other server, either; I kinda get what I'm given. Thanks, though! – Luke Jun 17 '15 at 02:18
4

It doesn't seem like you are having a race condition, because the ID is coming from the import file, and if your import algorithm is working correctly then each thread would have its own shard of the work to be done and should never conflict with others. Now it seems like 2 threads are receiving a request to create the same patient and get in conflict with eachother because of bad algorithm.

conflictfree

Make sure that each spawned thread gets a new row from the import file, and repeat only on failure.

If you cant do that, and want to stick to mutex, using a file lock doesn't seem like a very nice solution, since now you solved the conflict within the application, while it is actually occurring in your database. A DB lock should be a lot faster too, and overall a more decent solution.

Request a database lock, like this:

$db -> exec('LOCK TABLES table1 WRITE, table2 WRITE');

And you can expect a SQL error when you would write to a locked table, so surround your Patient->save() with a try catch.

An even better solution would be to use a conditional atomic query. A DB query that also has the condition within it. You could use a query like this:

INSERT INTO targetTable(field1) 
SELECT field1
FROM myTable
WHERE NOT(field1 IN (SELECT field1 FROM targetTable))
RoyB
  • 3,104
  • 1
  • 16
  • 37
  • Multiple simultaneous imports is not desirable, I just want one, so no need to implement something to share the work between threads. What makes you say that the conflict is occurring in the database? I agree that the DB layer is where the exception is coming from, but that's because two threads are doing something they shouldn't at the application layer. Is it possible to use DB locks to prevent them from both importing? I THINK I'd like them to not block out writes entirely, since others may be reading/saving through normal use (not import). – Luke Jun 15 '15 at 00:28
  • Ah, I tought it were seperate threads, my bad. You DO want to have a write lock, because your application is reading the state, then handling according to the state that it assumed earlier. During the period between when you read status, and write your update according to the status, you dont want the state to change in the meanwhile. Exactly that use case where other scipts change the same data at the same time will make your scripts bite eachother. Also, please note that a write lock on the table still allows reads, just not updates/inserts. – RoyB Jun 15 '15 at 12:26
  • 1
    I suggest you read up on DB locking: both optimistic and pessimistic locking (AKA read and write locking), This post gives a nice heads up: http://stackoverflow.com/questions/129329/optimistic-vs-pessimistic-locking It's not simple, but I believe its mandatory for every programmer to be aware of to be able to write resilient software. – RoyB Jun 15 '15 at 12:35
  • Thanks, Roy. Would you mind taking a look at the OP, I've attempted something with mutexes. Doing at app level rather than DB level means that I only send one request to the other server, so subsequent 'colliding' requests to the main served can be responded to ASAP. Thoughts? – Luke Jun 17 '15 at 02:19
  • The lock seems implemented in the right manner, just mind that you dont get a table lock with this. So any other application that talks to the DB is not constrained as you would have had with a table lock. Other clients of the DB can "work around" your named lock by just not checking it. Also, if you want to do the whole thing in 1 request, you shouldnt call save on all these entities, and build up 1 big query.. Now it seems to me each save(); would send out a query. Also, when your mysql-connection closes, your lock is automatically released. – RoyB Jun 17 '15 at 13:59
-1

I see three options:
- use mutex/semaphore/some other flag - not easy to code and maintain
- use DB built-in transaction mechanism
- use queue (like RabbitMQ or 0MQ) to write messages into DB in a row

He11ion
  • 56
  • 2
  • 7
  • I think I'd rather avoid just doing concurrency checking at the DB level, because it's too late; by that point, I've already hit the other server to get the data to import twice. I'd rather the check happened early enough to prevent the second request from doing any work in the import() method. So it seems only the first option satisfies that, correct? – Luke Jun 03 '15 at 07:24
  • As i see the MQ is a good option - put your control structures in one place and let it decide what must be inserted. – He11ion Jun 03 '15 at 07:38
  • At the moment, a standard framework called Eloquent is taking care of all the database interaction. Perhaps I don't understand what you're suggesting, but it seems like it'd be difficult to jimmy in a queue into that framework? And I still feel that the check should happen earlier, such that the second request to the other server doesn't happen. – Luke Jun 05 '15 at 00:10
  • This is not a full and useful answer... Please use comments when suggesting solution directions, and submit an answer when you have a full answer/solution to a question. – RoyB Jun 12 '15 at 15:07