-1

I have a MySQL table that manages jobs that worker-clients can lease for processing. Apart from the columns that describe the job, the table has a unique primary key column id, a time-stamp-column lease, a boolean-column complete, and an int-column priority.

I'm trying to write a (set of) SQL statement(s) that will manage the leasing-process. My current plan is to find the first incomplete job that has a lease-date that is at least 8 hours in the past (no job should take more than one hour, so an incomplete lease that is that old probably means that the client died and the job needs to be restarted), set its lease-date to the current time-stamp, and return its info. All of this, of course, needs to happen atomically.

I found a neat trick here on SO and a variation of it in the discussion of the MySQL documentation (see post on 7-29-04 here) that uses user-defined variables to return the leased job from an UPDATE statement.

And, indeed, this works fine:

UPDATE jobs SET lease=NOW() WHERE TIMESTAMPDIFF(HOUR,lease,NOW())>=8 AND NOT complete AND @id:=id LIMIT 1;
SELECT * FROM jobs WHERE id=@id;

The problem comes in when I try to add priorities to the jobs and add ORDER BY priority into the UPDATE statement right before LIMIT. The UPDATE still works as expected, but the SELECT always returns the same row back (either the first or the last, but not the one that was actually updated). I'm a little confused by this, since LIMIT 1 should make sure that the first update that actually happens will terminate the UPDATE process, leaving @id set to the correct value of that updated row, no? For some reason it seems to keep evaluating the condition @id:=id for all rows anyways, even after it's done with its update (or maybe it evaluates it first for all rows before even figuring out which one to update, I don't know...).

To fix this, I tried rewriting the statement to make sure the variable really only gets set for the matching row:

UPDATE jobs SET lease=NOW(),@id:=id WHERE TIMESTAMPDIFF(HOUR,lease,NOW())>=8 AND NOT complete ORDER BY priority LIMIT 1;

But for some reason, this gives me the following error:

Error Code : 1064
You have an error in your SQL syntax; check the manual that corresponds
to your MySQL server version for the right syntax to use near
'@id:=id WHERE TIMESTAMPDIFF(HOUR,lease,NOW())>=8 AND NOT complete ORDER BY prior'
at line 1

So, it seems that I can't assign the variable in the SET-part of the UPDATE (although this was the way it was suggested in the SO-answer linked above).

Can this approach be salvaged somehow or is there a better one altogether?

PS: I'm using MySQL server v5.5.44-0+deb8u1

HoldOffHunger
  • 18,769
  • 10
  • 104
  • 133
Markus A.
  • 12,349
  • 8
  • 52
  • 116

3 Answers3

1

My solution with a little trick: first: you must use a subselect so that UPDATE not nows thats the same table an second: you must initialize the @id with "(SELECT @id:=0)" else if the found no row they returns the last set value. Here you can also specify if they return 0 or '' when no result is found.

UPDATE jobs SET lease=NOW() WHERE id =
  ( SELECT * FROM 
     ( SELECT @id:=id  FROM jobs,(SELECT @id:=0) AS tmp_id
       WHERE TIMESTAMPDIFF(HOUR,lease,NOW())>=8 
       AND NOT complete ORDER BY priority LIMIT 1
     ) AS tmp
  );
Bernd Buffen
  • 14,525
  • 2
  • 24
  • 39
  • Good point that @id will return the last value if no row is found. Initializing it to 0 will definitely be a way to check. But since `UPDATE` returns the number of affected rows, I could also just check that value and make sure it's 1, correct? Do you know how your solution would compare to the above suggestion from a performance point of view? Are sub-selects expensive? – Markus A. Sep 16 '15 at 21:43
  • sub-selects can be expensive, and they will be expensive if they are called often. Here it is one scan inside select, finding a row and then kind of post-processing. This is really a tiny overhead compared to the select itself. The expensive thing is the `ORDER BY ... limit 1`. This could be optimized away in application code, but only if query time is a problem (>>100K of jobs I think depending on the machine). – flaschenpost Sep 17 '15 at 06:02
1

It is OK that you found a solution.

If this must be quite stable, I would go for a different solution. I would not use atomicity, but "commit"- like workflows. You should identify your worker-client with a unique key, either in it's own table or with a secure hash key. You add two fields to your jobs-table: worker and state. So if you look for a job for worker W345, you assign worker to that job.

First part would be

 update jobs set worker='W345', state='planning', lease=now()
 where TIMESTAMPDIFF(HOUR,lease,NOW())>=8 
   AND NOT complete 
 ORDER BY priority LIMIT 1;

Next part (could be even from different part of application)

select * from jobs where worker='W345' and state='planning';

get id and data, update:

update jobs set state='sending', lease=now() where id=...;

Maybe you even can commit the sending of the job, otherwise you guess that it started after sending.

update jobs set state='working', lease=now() where id = ...;

You find all jobs that are dead before being sent to worker by their state and some short minutes old lease. You can find out where the process got into trouble. You can find out which workers get most trouble, and so on.

Maybe the real details differ, but as long as you have some status column you should be quite flexible and find your solution.

flaschenpost
  • 2,205
  • 1
  • 14
  • 29
  • I definitely need a stable solution. Is there a logical issue with my approach? If so, could you give me some details on how it might fail? I can definitely see that it is helpful in most cases to know which worker died at what stage in the process. In my scenario, though, all workers are identical and simply run on different machines in a cluster. Any errors would not be worker-specific and thus I doubt I would need this information. So, while a great idea, unless there's an actual problem with my approach, I'd rather not trade its simplicity and performance for the added transparency. – Markus A. Sep 16 '15 at 21:56
  • The only difference is that the worker can send a commit in my solution. Your atomic action is atomic in database, that is for sure, but if your program fails after that update and before the worker has really gotten the job (maybe network or whatever), your job has to wait another 8 hours. The worker-identification is just one way to find the row that you just have chosen. You could for example want to mix in some lower priority jobs with some low probability. Just keep the basic idea in mind if you need another extension of your solution beyond the "Hack". – flaschenpost Sep 17 '15 at 05:58
  • I definitely will. I'm sure this approach will come in handy one day. It's much more flexible than what I use now. But in this case, I think the simple approach will suffice and probably lead to better performance (only a single UPDATE), so I'll stick with it this time. Thank you, though! This has most certainly been helpful. – Markus A. Sep 20 '15 at 03:53
0

I was able to fix things with the following hack:

UPDATE jobs SET lease=IF(@id:=id,NOW(),0) WHERE TIMESTAMPDIFF(HOUR,lease,NOW())>=8 AND NOT complete ORDER BY priority LIMIT 1;

Seems like it's simply not allowed to set a local variable within the SET section of UPDATE.

Note:
Since the id column is an auto-increment primary key, it is never 0 or NULL. Thus, the assignment @id:=id inside the IF-statement should always evaluate to TRUE and therefore lease should be set correctly (correct me if I'm wrong on this, please!).

One thing to keep in mind:
The variable @id by default is scoped to the MySQL connection (not any Java Statement-object, for example, or similar), so if one connection is to be used for multiple job-leases, one needs to ensure that the different UPDATE/SELECT-pairs never get interleaved. Or one could add an increasing number to the variable-name (@id1, @id2, @id3, ...) to guarantee correct results, but I don't know what performance (or memory-use) impact this will have on the MySQL-server. Or, the whole thing could be packaged up into a stored procedure and the variable declared as local.

Markus A.
  • 12,349
  • 8
  • 52
  • 116
  • Seems you can't put variable assignment in SET part for mysql5.6, so my trick is to do it in WHERE clause: ``` UPDATE jobs SET lease=NOW() WHERE TIMESTAMPDIFF(HOUR,lease,NOW())>=8 AND NOT complete AND @id:=id ORDER BY priority LIMIT 1; SELECT @id; ``` – Shusen Liu Oct 17 '18 at 21:33