Skip to content

Commit 0f01888

Browse files
Allow to abort jobs (#8)
1 parent a07a37a commit 0f01888

11 files changed

Lines changed: 262 additions & 88 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ requirements:
22
pip install -r requirements-dev.txt
33

44
test:
5-
python -m pytest
5+
python -m pytest --cov=arq_admin --cov-fail-under 100 --cov-report term-missing
66

77
coverage-collect:
88
coverage run -m pytest

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,9 @@ urlpatterns = [
5555
ARQ_DESERIALIZER_BY_QUEUE = {
5656
'arq:another_queue_name': custom_job_deserializer,
5757
}
58-
```
58+
```
59+
60+
- You can change timeout for job aborting:
61+
```python
62+
ARQ_JOB_ABORT_TIMEOUT = 10
63+
```

arq_admin/queue.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
from contextlib import suppress
23
from dataclasses import dataclass
34
from typing import List, Optional, Set
45

@@ -78,6 +79,20 @@ async def get_job_by_id(self, job_id: str, redis: Optional[ArqRedis] = None) ->
7879
return await self._get_job_by_id(job_id, redis)
7980
return await self._get_job_by_id(job_id, redis)
8081

82+
async def abort_job(self, job_id: str) -> Optional[bool]:
83+
# None here means we are not sure if the job was aborted or not
84+
async with get_redis(self.redis_settings) as redis:
85+
arq_job = ArqJob(
86+
job_id=job_id,
87+
redis=redis,
88+
_queue_name=self.name,
89+
_deserializer=settings.ARQ_DESERIALIZER_BY_QUEUE.get(self.name),
90+
)
91+
with suppress(asyncio.TimeoutError):
92+
return await arq_job.abort(timeout=settings.ARQ_JOB_ABORT_TIMEOUT)
93+
94+
return None
95+
8196
async def _get_job_by_id(self, job_id: str, redis: ArqRedis) -> JobInfo:
8297
arq_job = ArqJob(
8398
job_id=job_id,

arq_admin/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,5 @@
2424
raise ImproperlyConfigured('You cannot define both ARQ_DESERIALIZER and ARQ_DESERIALIZER_BY_QUEUE')
2525

2626
ARQ_DESERIALIZER_BY_QUEUE = defaultdict(lambda: ARQ_DESERIALIZER)
27+
28+
ARQ_JOB_ABORT_TIMEOUT = getattr(settings, 'ARQ_JOB_ABORT_TIMEOUT', 5)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{% extends "admin/base_site.html" %}
2+
{% load static %}
3+
4+
{% block title %}Job {{ object.id }} {{ block.super }}{% endblock %}
5+
6+
{% block extrastyle %}
7+
{{ block.super }}
8+
<style>
9+
.data {
10+
display: inline-block;
11+
float: left;
12+
width: 80%;
13+
font-size: 12px;
14+
padding-top: 3px;
15+
}
16+
</style>
17+
<link href="{% static 'admin/css/forms.css' %}" type="text/css" rel="stylesheet">
18+
{% endblock %}
19+
20+
{% block breadcrumbs %}
21+
<div class="breadcrumbs">
22+
<a href="{% url 'admin:index' %}">Home</a> &rsaquo;
23+
<a href="{% url 'arq_admin:home' %}">Django ARQ</a> &rsaquo;
24+
<a href="{% url 'arq_admin:all_jobs' queue_name %}">{{ queue_name }}</a> &rsaquo;
25+
<a href="{% url 'arq_admin:job_detail' queue_name object.job_id %}">{{ object.job_id }}</a> &rsaquo;
26+
<a href="{% url 'arq_admin:job_abort' queue_name object.job_id %}">{{ object.job_id }}</a>
27+
</div>
28+
{% endblock %}
29+
30+
{% block content_title %}<h1>Job Info</h1>{% endblock %}
31+
32+
{% block content %}
33+
<div id="content-main" class="delete-confirmation">
34+
<form method="post">
35+
{% csrf_token %}
36+
<h1>Are you sure you want to abort this job?</h1>
37+
<p>
38+
You need to have <b>allow_abort_jobs=True</b> set for the queue to be able to abort the job.
39+
<a href="https://arq-docs.helpmanual.io/#retrying-jobs-and-cancellation">More info</a>
40+
</p>
41+
42+
<input type="submit" value="Yes, I'm sure">
43+
<a class="button cancel-link" href="{% url 'arq_admin:job_detail' queue_name object.job_id %}">No, take me back</a>
44+
</form>
45+
</div>
46+
{% endblock %}

arq_admin/templates/arq_admin/job_detail.html

Lines changed: 83 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -4,118 +4,122 @@
44
{% block title %}Job {{ object.id }} {{ block.super }}{% endblock %}
55

66
{% block extrastyle %}
7-
{{ block.super }}
8-
<style>
9-
.data {
10-
display: inline-block;
11-
float: left;
12-
width: 80%;
13-
font-size: 12px;
14-
padding-top: 3px;
15-
}
16-
</style>
17-
<link href="{% static 'admin/css/forms.css' %}" type="text/css" rel="stylesheet">
7+
{{ block.super }}
8+
<style>
9+
.data {
10+
display: inline-block;
11+
float: left;
12+
width: 80%;
13+
font-size: 12px;
14+
padding-top: 3px;
15+
}
16+
</style>
17+
<link href="{% static 'admin/css/forms.css' %}" type="text/css" rel="stylesheet">
1818
{% endblock %}
1919

2020
{% block breadcrumbs %}
21-
<div class="breadcrumbs">
22-
<a href="{% url 'admin:index' %}">Home</a> &rsaquo;
23-
<a href="{% url 'arq_admin:home' %}">Django ARQ</a> &rsaquo;
24-
<a href="{% url 'arq_admin:all_jobs' queue_name %}">{{ queue_name }}</a> &rsaquo;
25-
<a href="{% url 'arq_admin:job_detail' queue_name object.job_id %}">{{ object.job_id }}</a>
26-
</div>
21+
<div class="breadcrumbs">
22+
<a href="{% url 'admin:index' %}">Home</a> &rsaquo;
23+
<a href="{% url 'arq_admin:home' %}">Django ARQ</a> &rsaquo;
24+
<a href="{% url 'arq_admin:all_jobs' queue_name %}">{{ queue_name }}</a> &rsaquo;
25+
<a href="{% url 'arq_admin:job_detail' queue_name object.job_id %}">{{ object.job_id }}</a>
26+
</div>
2727
{% endblock %}
2828

2929
{% block content_title %}<h1>Job Info</h1>{% endblock %}
3030

3131
{% block content %}
3232

33-
<div id="content-main">
33+
<div id="content-main">
34+
<div>
35+
<a href="{% url 'arq_admin:job_abort' queue_name object.job_id %}" class="deletelink">Abort Job</a>
36+
</div>
37+
3438
<fieldset class="module aligned">
35-
<div class="form-row">
36-
<div>
37-
<label class="required">ID:</label>
38-
<div class="data">{{ object.job_id }}</div>
39-
</div>
39+
<div class="form-row">
40+
<div>
41+
<label class="required">ID:</label>
42+
<div class="data">{{ object.job_id }}</div>
4043
</div>
44+
</div>
4145

42-
<div class="form-row">
43-
<div>
44-
<label class="required">Function:</label>
45-
<div class="data">{{ object.function }}</div>
46-
</div>
46+
<div class="form-row">
47+
<div>
48+
<label class="required">Function:</label>
49+
<div class="data">{{ object.function }}</div>
4750
</div>
51+
</div>
4852

49-
<div class="form-row">
50-
<div>
51-
<label class="required">Status:</label>
52-
<div class="data">{{ object.status }}</div>
53-
</div>
53+
<div class="form-row">
54+
<div>
55+
<label class="required">Status:</label>
56+
<div class="data">{{ object.status }}</div>
5457
</div>
58+
</div>
5559

56-
<div class="form-row">
57-
<div>
58-
<label class="required">Args:</label>
59-
<div class="data">{{ object.args }}</div>
60-
</div>
60+
<div class="form-row">
61+
<div>
62+
<label class="required">Args:</label>
63+
<div class="data">{{ object.args }}</div>
6164
</div>
65+
</div>
6266

63-
<div class="form-row">
64-
<div>
65-
<label class="required">Kwargs:</label>
66-
<div class="data">{{ object.kwargs }}</div>
67-
</div>
67+
<div class="form-row">
68+
<div>
69+
<label class="required">Kwargs:</label>
70+
<div class="data">{{ object.kwargs }}</div>
6871
</div>
72+
</div>
6973

70-
<div class="form-row">
71-
<div>
72-
<label class="required">Enqueue time:</label>
73-
<div class="data">{{ object.enqueue_time }}</div>
74-
</div>
74+
<div class="form-row">
75+
<div>
76+
<label class="required">Enqueue time:</label>
77+
<div class="data">{{ object.enqueue_time }}</div>
7578
</div>
79+
</div>
7680

77-
<div class="form-row">
78-
<div>
79-
<label class="required">Start time:</label>
80-
<div class="data">{{ object.start_time }}</div>
81-
</div>
81+
<div class="form-row">
82+
<div>
83+
<label class="required">Start time:</label>
84+
<div class="data">{{ object.start_time }}</div>
8285
</div>
86+
</div>
8387

84-
<div class="form-row">
85-
<div>
86-
<label class="required">Finish time:</label>
87-
<div class="data">{{ object.finish_time }}</div>
88-
</div>
88+
<div class="form-row">
89+
<div>
90+
<label class="required">Finish time:</label>
91+
<div class="data">{{ object.finish_time }}</div>
8992
</div>
93+
</div>
9094

91-
<div class="form-row">
92-
<div>
93-
<label class="required">Job try:</label>
94-
<div class="data">{{ object.job_try }}</div>
95-
</div>
95+
<div class="form-row">
96+
<div>
97+
<label class="required">Job try:</label>
98+
<div class="data">{{ object.job_try }}</div>
9699
</div>
100+
</div>
97101

98-
<div class="form-row">
99-
<div>
100-
<label class="required">Success:</label>
101-
<div class="data">{{ object.success }}</div>
102-
</div>
102+
<div class="form-row">
103+
<div>
104+
<label class="required">Success:</label>
105+
<div class="data">{{ object.success }}</div>
103106
</div>
107+
</div>
104108

105-
<div class="form-row">
106-
<div>
107-
<label class="required">Result:</label>
108-
<div class="data">{{ object.result }}</div>
109-
</div>
109+
<div class="form-row">
110+
<div>
111+
<label class="required">Result:</label>
112+
<div class="data">{{ object.result }}</div>
110113
</div>
114+
</div>
111115

112-
<div class="form-row">
113-
<div>
114-
<label class="required">Score:</label>
115-
<div class="data">{{ object.score }}</div>
116-
</div>
116+
<div class="form-row">
117+
<div>
118+
<label class="required">Score:</label>
119+
<div class="data">{{ object.score }}</div>
117120
</div>
121+
</div>
118122
</fieldset>
119-
</div>
123+
</div>
120124

121125
{% endblock %}

arq_admin/urls.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from django.urls import path
22

33
from arq_admin.views import (
4-
AllJobListView, DeferredJobListView, JobDetailView, QueuedJobListView,
5-
QueueListView, RunningJobListView,
4+
AllJobListView, DeferredJobListView, JobAbortView, JobDetailView,
5+
QueuedJobListView, QueueListView, RunningJobListView,
66
)
77

88
app_name = 'arq_admin'
@@ -13,4 +13,5 @@
1313
path('queue/<str:queue_name>/running/', RunningJobListView.as_view(), name='running_jobs'),
1414
path('queue/<str:queue_name>/deferred/', DeferredJobListView.as_view(), name='deferred_jobs'),
1515
path('queue/<str:queue_name>/<str:job_id>/', JobDetailView.as_view(), name='job_detail'),
16+
path('queue/<str:queue_name>/<str:job_id>/abort', JobAbortView.as_view(), name='job_abort'),
1617
]

arq_admin/views.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import asyncio
2+
from functools import cached_property
23
from operator import attrgetter
34
from typing import Any, Dict, List, Optional
45

56
from arq.jobs import JobStatus
6-
from django.contrib import admin
7+
from django.contrib import admin, messages
78
from django.contrib.admin.views.decorators import staff_member_required
9+
from django.http import HttpRequest, HttpResponse
10+
from django.shortcuts import redirect
811
from django.utils.decorators import method_decorator
912
from django.views.generic import DetailView, ListView
1013

@@ -95,3 +98,31 @@ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
9598
context['queue_name'] = self.kwargs['queue_name']
9699

97100
return context
101+
102+
103+
class JobAbortView(DetailView):
104+
template_name = 'arq_admin/job_abort.html'
105+
106+
def get_object(self, queryset: Optional[Any] = None) -> JobInfo:
107+
return asyncio.run(self._queue.get_job_by_id(self.kwargs['job_id']))
108+
109+
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
110+
context = super().get_context_data(**kwargs)
111+
context['queue_name'] = self.kwargs['queue_name']
112+
113+
return context
114+
115+
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: # pragma: nocover
116+
aborted = asyncio.run(self._queue.abort_job(self.kwargs['job_id']))
117+
if aborted:
118+
messages.success(request, 'Job aborted successfully')
119+
elif aborted is None:
120+
messages.warning(request, 'An issue happened while aborting the job, but it may be aborted anyway')
121+
else:
122+
messages.error(request, 'Job was not aborted')
123+
124+
return redirect('arq_admin:job_detail', queue_name=self.kwargs['queue_name'], job_id=self.kwargs['job_id'])
125+
126+
@cached_property
127+
def _queue(self) -> Queue: # pragma: nocover
128+
return Queue.from_name(self.kwargs['queue_name'])

setup.cfg

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,6 @@ filterwarnings =
6868
ignore::UserWarning:pytest.*:
6969
ignore::ResourceWarning:redis.*:
7070
junit_family = xunit1
71-
addopts =
72-
--cov=arq_admin
73-
--cov-fail-under 100
74-
--cov-report term-missing
7571

7672
[coverage:run]
7773
source = arq_admin

0 commit comments

Comments
 (0)