Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[5.x] Add ability to clear queues from dashboard #890

Closed
wants to merge 4 commits into from
Closed
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
43 changes: 41 additions & 2 deletions resources/js/screens/dashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
*/
data() {
return {
purge: {
queue: null,
message: null,
confirm: false,
},
stats: {},
workers: [],
workload: [],
Expand Down Expand Up @@ -148,7 +153,31 @@
*/
determinePeriod(minutes) {
return moment.duration(moment().diff(moment().subtract(minutes, "minutes"))).humanize().replace(/^An?/i, '');
}
},


/**
* Displays confirmation to clear queued jobs from given queue
*/
confirmClearQueue(queue) {
this.purge.queue = queue.name;
this.purge.message = `Are you sure you want to clear the '${queue.name}' queue?`;
this.purge.confirm = true;

this.$nextTick(() => {
this.$refs.purgeModal.open();
});
},


/**
* Clears queued jobs from given queue
*/
clearQueue() {
this.$http.post(`/${Horizon.path}/api/clearQueue`, { queue: this.purge.queue }).then(response => {
this.purge.queue = null;
});
}
}
}
</script>
Expand Down Expand Up @@ -272,6 +301,7 @@
<th>Queue</th>
<th>Processes</th>
<th>Jobs</th>
<th v-if="Horizon.supportsClear">Clear</th>
<th class="text-right">Wait</th>
</tr>
</thead>
Expand All @@ -283,6 +313,15 @@
</td>
<td>{{ queue.processes ? queue.processes.toLocaleString() : 0 }}</td>
<td>{{ queue.length ? queue.length.toLocaleString() : 0 }}</td>
<td v-if="Horizon.supportsClear">
<button class="btn btn-outline-danger" title="Clear queued jobs" :disabled="purge.queue === queue.name" @click="confirmClearQueue(queue)">

<svg class="icon fill-danger" viewBox="0 0 20 20">
<path d="M19 24h-14c-1.104 0-2-.896-2-2v-17h-1v-2h6v-1.5c0-.827.673-1.5 1.5-1.5h5c.825 0 1.5.671 1.5 1.5v1.5h6v2h-1v17c0 1.104-.896 2-2 2zm0-19h-14v16.5c0 .276.224.5.5.5h13c.276 0 .5-.224.5-.5v-16.5zm-9 4c0-.552-.448-1-1-1s-1 .448-1 1v9c0 .552.448 1 1 1s1-.448 1-1v-9zm6 0c0-.552-.448-1-1-1s-1 .448-1 1v9c0 .552.448 1 1 1s1-.448 1-1v-9zm-2-7h-4v1h4v-1z"/>
</svg>

</button>
</td>
<td class="text-right">{{ humanTime(queue.wait) }}</td>
</tr>
</tbody>
Expand Down Expand Up @@ -318,6 +357,6 @@
</table>
</div>


<alert ref="purgeModal" :message="purge.message" type="confirmation" v-if="purge.confirm" :confirmation-proceed="clearQueue"></alert>
</div>
</template>
1 change: 1 addition & 0 deletions resources/sass/base.scss
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ svg.icon {
}

button:hover {
.fill-danger,
.fill-primary {
fill: #fff;
}
Expand Down
1 change: 1 addition & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
Route::prefix('api')->group(function () {
// Dashboard Routes...
Route::get('/stats', 'DashboardStatsController@index')->name('horizon.stats.index');
Route::post('/clearQueue', 'QueueClearController')->name('horizon.queue-clear.store');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unclear to me why a delete operation would be a POST. Shouldn't it be something like DELETE /queues/{queue-name} or something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought of clearing queues as more of an action rather than deleting a resource. If you'd like I can change it to a delete operation.


// Workload Routes...
Route::get('/workload', 'WorkloadController@index')->name('horizon.workload.index');
Expand Down
1 change: 1 addition & 0 deletions src/Horizon.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ public static function scriptVariables()
{
return [
'path' => config('horizon.path'),
'supportsClear' => method_exists(RedisQueue::class, 'clear'),
];
}

Expand Down
27 changes: 27 additions & 0 deletions src/Http/Controllers/QueueClearController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Laravel\Horizon\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Queue\QueueManager;
use Illuminate\Support\Arr;
use Laravel\Horizon\Repositories\RedisJobRepository;

class QueueClearController extends Controller
{
/**
* Clear the specified queue.
*
* @param \Laravel\Horizon\Repositories\RedisJobRepository $jobRepository
* @param \Illuminate\Queue\QueueManager $manager
* @param \Illuminate\Http\Request $request
* @return void
*/
public function __invoke(RedisJobRepository $jobRepository, QueueManager $manager, Request $request)
{
$jobRepository->purge($queue = $request->input('queue'));

$connection = Arr::first(config('horizon.defaults'))['connection'] ?? 'redis';
$manager->connection($connection)->clear($queue);
}
}
21 changes: 21 additions & 0 deletions src/Repositories/RedisJobRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,27 @@ public function deleteFailed($id)
$this->connection()->del($id);
}

/**
* Delete pending and reserved jobs for a queue.
*
* @param string $queue
* @return void
*/
public function purge($queue)
{
$ids = collect(range(0, ceil($this->nextJobId() / 50)))->transform(function ($_, $index) use ($queue) {
return $this->getRecent($index === 0 ? null : $index * 50)->filter(function ($recent) use ($queue) {
return in_array($recent->status, ['pending', 'reserved']) && $recent->queue === $queue;
});
})->flatten(1)->pluck('id')->all();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please explain what this is doing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we're collecting the IDs of the pending or reserved jobs on the specified queue. To do so, we get all the recent jobs (50 at a time based on the in-built RedisJobRepository methods) and filter through them to check 1) if they're either pending or reserved and 2) if the queue matches the specified queue. On the filtered set of jobs, we pluck the job IDs and then delete them from the Redis hash and recent/pending jobs sorted sets.


$this->connection()->pipeline(function ($pipe) use ($ids) {
$pipe->zrem('recent_jobs', $ids);
$pipe->zrem('pending_jobs', $ids);
$pipe->del($ids);
});
}

/**
* Get the Redis connection instance.
*
Expand Down
24 changes: 24 additions & 0 deletions tests/Controller/QueueClearControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace Laravel\Horizon\Tests\Controller;

use Laravel\Horizon\RedisQueue;
use Laravel\Horizon\Repositories\RedisJobRepository;
use Mockery;

class QueueClearControllerTest extends AbstractControllerTest
{
public function test_it_removes_all_job_from_specific_queue()
{
Mockery::mock(RedisJobRepository::class)
->shouldReceive('purge')
->withArgs(['email-processing']);

Mockery::mock(RedisQueue::class)
->shouldReceive('clear')
->withArgs(['email-processing']);

$this->actingAs(new Fakes\User)
->post('/horizon/api/clearQueue', ['queue' => 'email-processing']);
}
}
24 changes: 24 additions & 0 deletions tests/Feature/RedisJobRepositoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,28 @@ public function test_it_saves_microseconds_as_a_float_and_disregards_the_locale(
throw $e;
}
}

public function test_it_removes_recent_jobs_when_queue_is_purged()
{
$repository = $this->app->make(JobRepository::class);

$repository->pushed('horizon', 'email-processing', new JobPayload(json_encode(['id' => 1, 'displayName' => 'first'])));
$repository->pushed('horizon', 'email-processing', new JobPayload(json_encode(['id' => 2, 'displayName' => 'second'])));
$repository->pushed('horizon', 'email-processing', new JobPayload(json_encode(['id' => 3, 'displayName' => 'third'])));
$repository->pushed('horizon', 'email-processing', new JobPayload(json_encode(['id' => 4, 'displayName' => 'fourth'])));
$repository->pushed('horizon', 'email-processing', new JobPayload(json_encode(['id' => 5, 'displayName' => 'fifth'])));

$repository->completed(new JobPayload(json_encode(['id' => 1, 'displayName' => 'first'])));
$repository->completed(new JobPayload(json_encode(['id' => 2, 'displayName' => 'second'])));

$repository->purge('email-processing');

$this->assertEquals(2, $repository->countRecent());
$this->assertEquals(0, $repository->countPending());
$this->assertEquals(2, $repository->countCompleted());

$recent = collect($repository->getRecent());
$this->assertNotNull($recent->firstWhere('id', 1));
$this->assertNotNull($recent->firstWhere('id', 2));
}
}