3

I have an application that can support a certain number of concurrent actions. This is represented by a table of "slots" in postgres. When nodes come online, they insert a number of rows into the table, one per slot. As jobs claim the slots, they update a row in the table claiming one of the slots and release it again as they finish.

The slots table looks like this:

CREATE TABLE slots (
    id INT8 PRIMARY KEY DEFAULT nextval('slots_seq'),
    node_name TEXT NOT NULL,
    job_name TEXT
);

At any time it has some semi-fixed number of rows each of which may or may not have a job_name filled in.

When a new job wants to start up, it runs these queries to get the name of the node it should run on:

BEGIN;
LOCK TABLE slots IN ACCESS EXCLUSIVE MODE;
SELECT id, node_name
    FROM slots
    WHERE job_name IS NULL
    LIMIT 1
    FOR UPDATE;

(the node_name and id are read out of the cursor)

UPDATE slots
    SET job_name = %(job_name)s
    WHERE id = %(slot_id)s;
COMMIT;

This is often able to claim rows without losing any updates but with higher levels of concurrency, only a few rows will be claimed while many SELECT ... FOR UPDATE and UPDATE queries have been executed. The net result is that we end up with far more jobs running than there are slots for them.

Am I making a locking error? Is there a better way to go about this? Something that doesn't use table locks?

Transaction level SERIALIZABLE does not cut it, only a handful of rows are ever filled.

I'm using postgresql version 8.4.

joegester
  • 307
  • 1
  • 3
  • 11
  • Well, I'd stick a debugging select/notice combo before the update to display how many rows are matching your job_name = %(job_name)s restriction (does whatever language you are using for this automatically 'quote' the %(foo)s syntax?) In other news, I'd do a safety check during the reservation step to check to see if the job_name already has a slot reserved. – Seth Robertson Jun 27 '11 at 19:36
  • I'll give that a shot. Thanks, Seth. (Yes the %(variable)s thing is filled in by the python interface I'm using.) – joegester Jun 27 '11 at 19:52
  • I hope you handle the fact that you first request with limit 1 can be successfull without returning any available slot. You use 2 different table names 'slots' and 'server_slots', a mistake?. And why don't you reuse the slot_id when releasing, if 2 jobs get the same name you will release too much slots? – regilero Jun 27 '11 at 20:57
  • regilero: I do handle the case where no slots are available, the requesting code gets and error back. I just fixed the slots/server_slots problem. The job names are globally unique. UUIDs actually. – joegester Jun 27 '11 at 21:12
  • I just edited to simplify the problem. Further experimentation shows that I can cause updates to be dropped without having to release any slots. – joegester Jun 27 '11 at 21:12
  • Are you observing (and handling) any deadlocks that might be "thrown" in here , and hows does the code that release a job look like ? – nos Jun 27 '11 at 23:55
  • I haven't seen any deadlocks. The code that releases a lock was in a previous version of the post but even without ever calling it, I see it regularly lose updates with enough concurrency. – joegester Jun 28 '11 at 03:36

3 Answers3

4
BEGIN; 
LOCK TABLE slots IN ACCESS EXCLUSIVE MODE; 
UPDATE slots SET job_name = '111' WHERE id IN (SELECT id FROM slots WHERE job_name IS NULL LIMIT 1) RETURNING *;
COMMIT;

This seems to work in Read Committed. It is only sql (same as your code) and can be executed in one call (faster).

@Seth Robertson: It is not safe without LOCK TABLE and without while loop.

If there is transaction A and transaction B at same time: A will select first row and B will select first row. A will lock and update row, B have to wait until A commit. Then B will recheck condition job_name IS NULL. It is false and B will not update - B will not select next row but will only recheck and return empty result.

@joegester: SELECT FOR UPDATE is not the problem because all table is locked.

Maybe there is another way to do job - if you delete and insert rows (in other table?) instead setting NULL. But I am not sure how.

jordani
  • 436
  • 3
  • 3
  • Another good idea. I was actually doing this at one point but now I can't recall why I changed it to the SELECT FOR UPDATE version. It may not just be faster, if the SELECT FOR UPDATE is the problem, maybe this will circumvent that issue. – joegester Jun 28 '11 at 03:38
  • Can you explain why it isn't safe? Or are you saying, it would lose updates if the while loop I put in there wasn't there if there were no free slots. Which is certain true, even if you locked the whole table. – Seth Robertson Jun 28 '11 at 03:41
  • 1
    I would not recommend ACCESS EXCLUSIVE mode. It will cause your application to be blocked by pg_dump (and you are backing up your database, right?). I recommend using EXCLUSIVE mode instead. See http://stackoverflow.com/questions/6507475/job-queue-as-sql-table-with-multiple-consumers-postgresql/6702355#6702355 – apinstein Jul 18 '11 at 18:48
2

Well, I wrote a program in perl to simulate what was going on since I didn't think that what you were saying was possible. Indeed after running my simulation I didn't have any problems even when I turned locking off (since SELECT … FOR UPDATE and UPDATE should do the necessary locking).

I ran this on PG 8.3 and PG 9.0 and it worked fine on both locations.

I urge you to try the program and/or try a python version to have a nice tight test-case which you can share with the class. If it does work, you can investigate what the differences are and if it doesn't work, you have something that other people can play with.

#!/usr/bin/perl
use DBI;
$numchild = 0;
$SIG{CHLD} = sub { if (wait) {$numchild--;} };

sub worker($)
{
  my ($i) = @_;
  my ($job);

  my $dbh = DBI->connect("dbi:Pg:host=localhost",undef,undef,{'RaiseError'=>0, 'AutoCommit'=>0});

  my ($x) = 0;
  while(++$x)
  {
#    $dbh->do("lock table slots in access exclusive mode;") || die "Cannot lock at $i\n";
    my @id = $dbh->selectrow_array("select id from slots where job_name is NULL LIMIT 1 FOR UPDATE;");

    if ($#id < 0)
    {
      $dbh->rollback;
      sleep(.5);
      next;
    }
    $job = "$$-$i-($x)";
    $dbh->do("update slots set job_name='$job' where id=$id[0];") || die "Cannot update at $i\n";
    $dbh->commit || die "Cannot commit\n";
    last;
  }
  if (!$job)
  {
    print STDERR "Could not find slots in 5 attempts for $i $$\n" if ($ENV{'verbose'});
    return;
  }
  else
  {
    print STDERR "Got $job\n" if ($ENV{'verbose'} > 1);
  }
  sleep(rand(5));

#  $dbh->do("lock table slots in access exclusive mode;") || die "Cannot lock at $i\n";
  $dbh->do("update slots set usage=usage+1, job_name = NULL where job_name='$job';") || die "Cannot unlock $job";
  print STDERR "Unlocked $job\n" if ($ENV{'verbose'} > 2);
  $dbh->commit || die "Cannot commit";
}

my $dbh = DBI->connect("dbi:Pg:host=localhost",undef,undef,{'RaiseError'=>0, 'AutoCommit'=>0});

$dbh->do("drop table slots;");
$dbh->commit;
$dbh->do("create table slots (id serial primary key, job_name text, usage int);") || die "Cannot create\n";
$dbh->do("insert into slots values (DEFAULT,NULL,0), (DEFAULT,NULL,0), (DEFAULT,NULL,0), (DEFAULT,NULL,0), (DEFAULT,NULL,0), (DEFAULT,NULL,0), (DEFAULT,NULL,0), (DEFAULT,NULL,0), (DEFAULT,NULL,0), (DEFAULT,NULL,0);") || die "Cannot insert";
$dbh->commit;

for(my $i=0;$i<200;$i++)
{
  if (!fork)
  {
    worker($i);
    exit(0);
  }

  if (++$numchild > 50)
  {
    sleep(1);
  }
}
while (wait > 0)
{
  $numchild--;
  print "Waiting numchild $numchild\n";
  sleep(1);
}
my $dbh = DBI->connect("dbi:Pg:host=localhost",undef,undef,{'RaiseError'=>0, 'AutoCommit'=>0});
my $slots = $dbh->selectall_arrayref("select * from slots;") || die "Cannot do final select";
my $sum=0;
foreach my $slot (@$slots)
{
  printf("%02d %3d %s\n",$slot->[0], $slot->[2], $slot->[1]);
  $sum += $slot->[2];
}
print "Successfully made $sum entries\n";
Seth Robertson
  • 30,608
  • 7
  • 64
  • 57
  • Good idea. Tomorrow I'll make a much simpler version that's pulled out of our application. I'm concerned that the first paragraph of [the row locking documentation](http://www.postgresql.org/docs/8.4/interactive/explicit-locking.html#LOCKING-ROWS) says that a 2nd transaction can select the rows that are already locked but not yet updated, then overwrite the update once the 1st transaction completes. On the other hand, your example shows exactly how I thought this worked before I started down this path. I hope you're right. Thanks for putting all that effort into this! – joegester Jun 28 '11 at 02:12
  • @joegester: I read the documentation and what it is saying is that another transaction can READ the row, but that is very different from being able to select it `FOR UPDATE`. You are guaranteed that the `WHERE` clause will be true if you `FOR UPDATE` until your query updates those rows or you finish the transaction. – Seth Robertson Jun 28 '11 at 03:45
  • Thanks, Seth! I was fundamentally misunderstanding how SELECT FOR UPDATE works. – joegester Jun 28 '11 at 17:09
  • what should the script actually return? – Christian Schmitt May 11 '17 at 09:03
  • 1
    @Christian Schmitt:. It has been a long time but it looks like the successfully made print at the end should be 200. The table dump would be semi random. Hopefully fairly evenly distributed – Seth Robertson May 11 '17 at 13:52
1

You might want to look into advisory locks.

Haven't tested, but it might be possible to rewrite your locking query like so:

BEGIN;
SELECT id, node_name
    FROM slots
    WHERE job_name IS NULL
    AND pg_try_advisory_lock('slots'::regclass::int, id::int)
    LIMIT 1;

or, since you're using a bigint in the first place (you need that much ids?!?), something like:

BEGIN;
SELECT id, node_name
    FROM slots
    WHERE job_name IS NULL
    AND pg_try_advisory_lock(hashtext('slots_' || id))
    LIMIT 1;

Be wary of the gotchas if you do -- the advisory lock needs to be explicitly unlocked per session irrespective of whether the transaction succeeds or not.

There also is a risk of collision in the case of hashtext() but it's no big deal for you if you're processing jobs...

Denis de Bernardy
  • 75,850
  • 13
  • 131
  • 154