Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 27 additions & 14 deletions src/Models/QueueJobModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,23 +160,36 @@ private function popWithLock(string $name, array $priority): ?QueueJob
$builder = $this->setPriority($builder, $priority);
$sql = $builder->getCompiledSelect();

$query = $this->db->query($this->skipLocked($sql));
if ($query === false) {
$this->db->transComplete();
try {
$query = $this->db->query($this->skipLocked($sql));

return null;
}
/** @var QueueJob|null $row */
$row = $query->getCustomRowObject(0, QueueJob::class);
if ($query === false) {
return null;
}

if ($row !== null) {
// Change status
$this->update($row->id, ['status' => Status::RESERVED->value]);
}
// Complete transaction
$this->db->transComplete();
/** @var QueueJob|null $row */
$row = $query->getCustomRowObject(0, QueueJob::class);

return $row;
if ($row === null) {
return null;
}

// Change status. The job must not be processed unless the
// reservation is confirmed by exactly one affected row.
$updated = $this->update($row->id, ['status' => Status::RESERVED->value]);

if ($updated === false || $this->db->affectedRows() !== 1) {
$this->db->transRollback();

return null;
}

return $row;
} finally {
// Always close the transaction, even when a query fails or the
// reservation cannot be confirmed.
$this->db->transComplete();
}
}

/**
Expand Down
66 changes: 66 additions & 0 deletions tests/Models/QueueJobModelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,70 @@ public function testSetPriorityEscapesPriorityValues(): void
$this->assertStringContainsString($model->db->escape($priority['priority_key']), $sql);
$this->assertStringNotContainsString('priority_key', $sql);
}

public function testPopWithLockReturnsNullAndClosesTransactionWhenQueryFails(): void
{
$model = model(QueueJobModel::class);
$oldDBDebug = $this->getPrivateProperty($model->db, 'DBDebug');
$this->setPrivateProperty($model->db, 'DBDebug', false);

try {
$testModel = new class ($model->db) extends QueueJobModel {
protected $table = 'non_existent_table_xyz';
};
$method = $this->getPrivateMethodInvoker($testModel, 'popWithLock');

$result = $method('queue1', ['default']);

$this->assertNull($result);
$this->assertSame(0, $this->getPrivateProperty($testModel->db, 'transDepth'));
} finally {
$this->setPrivateProperty($model->db, 'DBDebug', $oldDBDebug);
}
}

public function testPopWithLockRollsBackAndReturnsNullWhenUpdateFails(): void
{
$existing = model(QueueJobModel::class);
$model = $this->getMockBuilder(QueueJobModel::class)
->setConstructorArgs([$existing->db])
->onlyMethods(['update'])
->getMock();
$model->method('update')->willReturn(false);

$method = $this->getPrivateMethodInvoker($model, 'popWithLock');
$result = $method('queue1', ['default']);

$this->assertNull($result);
$this->assertSame(0, $this->getPrivateProperty($model->db, 'transDepth'));
$this->seeInDatabase('queue_jobs', [
'id' => 2,
'status' => Status::PENDING->value,
]);
}

public function testPopWithLockRollsBackAndReturnsNullWhenAffectedRowsIsNotOne(): void
{
$existing = model(QueueJobModel::class);
$model = $this->getMockBuilder(QueueJobModel::class)
->setConstructorArgs([$existing->db])
->onlyMethods(['update'])
->getMock();
$model->method('update')->willReturnCallback(static function () use ($model): bool {
// Update 0 rows so affectedRows() is 0 instead of 1
$model->db->table('queue_jobs')->where('id', 999999)->update(['status' => 99]);

return true;
});

$method = $this->getPrivateMethodInvoker($model, 'popWithLock');
$result = $method('queue1', ['default']);

$this->assertNull($result);
$this->assertSame(0, $this->getPrivateProperty($model->db, 'transDepth'));
$this->seeInDatabase('queue_jobs', [
'id' => 2,
'status' => Status::PENDING->value,
]);
}
}
Loading