Skip to content

Commit 27f8714

Browse files
authored
Merge pull request #35 from netlogix/fix/stale-jobs
2 parents 1440a85 + 42324d1 commit 27f8714

2 files changed

Lines changed: 90 additions & 27 deletions

File tree

Classes/Command/SchedulerCommandController.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,6 @@ public function pollForIncomingJobsCommand(
136136
if (count($pool) === 0) {
137137
$pool->eventLoop->cancelTimer($ping);
138138
$pool->eventLoop->cancelTimer($checkForPoolToClear);
139-
$pool->eventLoop->stop();
140139
}
141140
}
142141
);

Classes/Domain/Scheduler.php

Lines changed: 90 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,18 @@ public function next(string $groupName): ?ScheduledJob
9090
$claim = Algorithms::generateUUID();
9191
$tableName = ScheduledJob::TABLE_NAME;
9292

93-
$update =
93+
$claimQuery =
9494
/**
95-
* Step 1: Create a derived table using the "idx_for_update" index
96-
* that only contains one row.
97-
* Step 2: Join that row against the actual job to be claimed on
98-
* the primary key column.
99-
* Step 3: UPDATE that row with the claim value.
95+
* Step 1: Insert the "claimed" value without locking.
96+
*
97+
* Step 1.1: Create a derived table using the "idx_for_update" index
98+
* that only contains one row.
99+
* Step 1.2: Join that row against the actual job to be claimed on
100+
* the primary key column.
101+
* Step 1.3: UPDATE that row with the claim value.
100102
*
101103
* Otherwise, MySQL would use the "idx_groupname" index, fetch
102-
* millions of rows, use a temp table to sort those millions
104+
* millions of rows, use a temp table to sort those millions,
103105
* and limit the result to the one row to be claimed.
104106
*
105107
* @lang MySQL
@@ -116,7 +118,7 @@ public function next(string $groupName): ?ScheduledJob
116118
INNER JOIN {$tableName}
117119
USING (identifier)
118120
SET claimed = :claimed,
119-
running = 1,
121+
running = 2,
120122
activity = NOW()
121123
WHERE claimed = ""
122124
MySQL;
@@ -128,7 +130,7 @@ public function next(string $groupName): ?ScheduledJob
128130
->onExceptionsOfType(RetryableException::class)
129131
->task(fn() => $this->dbal
130132
->executeQuery(
131-
$update,
133+
$claimQuery,
132134
[
133135
'now' => $this->timeBaseForDueDateCalculation->getNow(),
134136
'groupname' => $groupName,
@@ -141,15 +143,21 @@ public function next(string $groupName): ?ScheduledJob
141143
]
142144
));
143145

144-
$select = /** @lang MySQL */ <<<MySQL
146+
$selectQuery =
147+
/**
148+
* Step 2: Find the row in the database.
149+
*
150+
* @lang MySQL
151+
*/
152+
<<<"MySQL"
145153
SELECT identifier, duedate, queue, job, incarnation, claimed, running
146154
FROM {$tableName}
147155
WHERE claimed = :claimed
148156
AND groupname = :groupname
149157
MySQL;
150158
$row = $this->dbal
151159
->executeQuery(
152-
$select,
160+
$selectQuery,
153161
[
154162
'groupname' => $groupName,
155163
'claimed' => $claim,
@@ -165,6 +173,41 @@ public function next(string $groupName): ?ScheduledJob
165173
return null;
166174
}
167175

176+
$releaseQuery =
177+
/**
178+
* Step 3: Unlock the row and allow parallel processes to overwrite the "claimed" value
179+
*
180+
* @lang MySQL
181+
*/
182+
<<<"MySQL"
183+
UPDATE {$tableName}
184+
SET running = 1,
185+
activity = NOW()
186+
WHERE claimed = :claimed
187+
AND groupname = :groupname
188+
AND running = 2
189+
MySQL;
190+
(new Retry())
191+
/**
192+
* @see http://backoffcalculator.com/?attempts=5&rate=1&interval=0.5
193+
*/
194+
->withExponentialBackoff(retryInterval: 0.5, maxRetries: 5)
195+
->onExceptionsOfType(RetryableException::class)
196+
->task(fn() => $this->dbal
197+
->executeQuery(
198+
$releaseQuery,
199+
[
200+
'groupname' => $groupName,
201+
'claimed' => $claim,
202+
],
203+
[
204+
'groupname' => Types::STRING,
205+
'claimed' => Types::STRING,
206+
]
207+
));
208+
209+
210+
168211
return ScheduledJob::createInternal(
169212
job: $row['job'],
170213
queue: $row['queue'],
@@ -232,7 +275,6 @@ public function fail(ScheduledJob $job, string $reason): void
232275
running = 0,
233276
activity = NOW()
234277
WHERE identifier = :identifier
235-
AND claimed = :claimed
236278
LIMIT 1
237279
MySQL;
238280
$this->dbal
@@ -259,19 +301,16 @@ public function activity(ScheduledJob $job): void
259301
UPDATE {$tableName}
260302
SET activity = NOW()
261303
WHERE identifier = :identifier
262-
AND claimed = :claimed
263304
LIMIT 1
264305
MySQL;
265306
$this->dbal
266307
->executeQuery(
267308
$update,
268309
[
269310
'identifier' => $job->getIdentifier(),
270-
'claimed' => $job->getClaimed(),
271311
],
272312
[
273313
'identifier' => Types::STRING,
274-
'claimed' => Types::STRING,
275314
]
276315
);
277316
}
@@ -281,27 +320,52 @@ protected function scheduleJob(ScheduledJob $job): void
281320
$this->validateGroupName($job->getGroupName());
282321
$tableName = ScheduledJob::TABLE_NAME;
283322

284-
$statement = /** @lang MySQL */ <<<MySQL
323+
$statement =
324+
/**
325+
* `running = 0`:
326+
*
327+
* - Meaning: The existing job is currently pending.
328+
* - Set claimed to empty, which should be the case anyway.
329+
* - Use the lesser due date to avoid pushing jobs further and further into the future
330+
*
331+
* `running = 1`:
332+
*
333+
* - Meaning: The existing job is currently running.
334+
* - Set claimed to empty to cause a re-run, once the current run finishes.
335+
* - Use the upcoming due date, the current job is running anyway.
336+
*
337+
* `running = 2`:
338+
*
339+
* - Meaning: The existing job in its warmup phase.
340+
* - Keeping claimed "as is" is mandatory for the current run to pick up.
341+
* - The due date doesn't matter because once finished, the current run will vanish.
342+
*
343+
* @lang MySQL
344+
*/ <<<MySQL
285345
INSERT INTO {$tableName}
286346
(groupname, identifier, duedate, activity, queue, job, incarnation, claimed, running)
287347
VALUES (:groupname, :identifier, :duedate, NOW(), :queue, :job, :incarnation, :claimed, :running)
288348
ON DUPLICATE KEY
289349
UPDATE
290-
duedate = CASE
350+
duedate = CASE
351+
WHEN running = 0
352+
THEN IF(duedate < :duedate, duedate, :duedate)
291353
WHEN running = 1
292-
-- If the job is already running, this is "another one",
293-
-- so schedule the next one according to its own date.
294354
THEN :duedate
295-
WHEN duedate < :duedate
296-
-- If this reschedules a waiting job, use the lower value
355+
WHEN running = 2
297356
THEN duedate
298-
ELSE
299-
:duedate
300357
END,
301358
incarnation = :incarnation,
302-
queue = :queue,
303-
job = :job,
304-
claimed = :claimed
359+
queue = :queue,
360+
job = :job,
361+
claimed = CASE
362+
WHEN running = 0
363+
THEN :claimed
364+
WHEN running = 1
365+
THEN :claimed
366+
WHEN running = 2
367+
THEN claimed
368+
END
305369
MySQL;
306370

307371
(new Retry())

0 commit comments

Comments
 (0)