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

Add started and finished times to Job #50

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
4 changes: 2 additions & 2 deletions batch/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class ProjectAdmin(admin.ModelAdmin):
@admin.register(models.Job)
class JobAdmin(admin.ModelAdmin):
list_display = ["id", "owner", "status", "project", "external_id", "created_on", "updated_on"]
readonly_fields = ["owner", "project", "external_id", "created_on", "updated_on"]
list_filter = ["status", "created_on"]
readonly_fields = ["owner", "project", "external_id", "created_on", "updated_on", "started_on", "finished_on"]
list_filter = ["status", "created_on", "started_on", "finished_on"]
search_fields = ["id", "owner__username"]
ordering = ["-created_on"]
23 changes: 23 additions & 0 deletions batch/migrations/0007_auto_20200122_0827.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 2.2.9 on 2020-01-22 08:27

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('batch', '0006_auto_20200117_1459'),
]

operations = [
migrations.AddField(
model_name='job',
name='finished_on',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='job',
name='started_on',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
]
35 changes: 34 additions & 1 deletion batch/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import uuid
import datetime
import enum
import uuid

from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
Expand Down Expand Up @@ -82,6 +83,34 @@ def choices(cls):
return [(key.value, key.name) for key in cls]


class JobManager(models.Manager):
_STATUS_TRANSITIONS = {
(JobStatus.created, JobStatus.running): {"now": "started_on"},
(JobStatus.running, JobStatus.done): {"now": "finished_on"},
(JobStatus.running, JobStatus.failed): {"now": "finished_on"},
(JobStatus.done, JobStatus.collected): {},
}

def update_status(self, *, old: JobStatus, new: JobStatus) -> int:
Copy link
Contributor

Choose a reason for hiding this comment

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

Should also contain and id argument.

Copy link
Member Author

Choose a reason for hiding this comment

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

Should also contain and id argument.

Since querysets can be chained, filtering by ID can be done before updating where needed. This way, we can update status for any given queryset (e.g. for multiple jobs or by PK instead of external ID). Therefore, I suggest leaving update_status as a special case of generic update.

"""Update the job status of records with the old status to the new status.

Returns:
Number of changed rows.

Raises:
ValueError: Status transition is invalid.
"""
transition_info = self._STATUS_TRANSITIONS.get((old, new))
if transition_info is None:
raise ValueError(f"invalid job status transition from {old.value} to {new.value}")

extra_update_fields = {}
if "now" in transition_info:
extra_update_fields[transition_info["now"]] = datetime.datetime.utcnow()

return self.filter(status=old).update(status=new, **extra_update_fields)
Copy link
Contributor

Choose a reason for hiding this comment

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

Check returned value is 1. Given that we have id based lookup.

Copy link
Contributor

@Tomaz-Vieira Tomaz-Vieira Jan 27, 2020

Choose a reason for hiding this comment

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

Maybe we should have each transition as its own specific method, since they look like they have transition-specific logic, like that if "now" in transition info. It might be easier to maintain and customize transitions that look like

class JobExecution:
    ...
    def transition_to_running(self):
        update_time = datetime.datetime.utcnow()
        num_records = self.filter(status=JobStatus.created).update(status=JobStatus.running, started_on=update_time)
        if num_records != 1:
            raise SomeException(...)

Copy link
Member Author

Choose a reason for hiding this comment

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

Maybe we should have each transition as its own specific method, since they look like they have transition-specific logic, like that if "now" in transition info

I don't see any value for this right now because the transition-specific logic is minimal. Also, transitions are driven by a status value anyway.



class Job(models.Model):
"""
Represents remote job
Expand All @@ -93,12 +122,16 @@ class Job(models.Model):
owner = models.ForeignKey(User, on_delete=models.PROTECT, null=True, blank=True, editable=False)
created_on = models.DateTimeField(auto_now_add=True, editable=False)
updated_on = models.DateTimeField(auto_now=True, editable=False)
started_on = models.DateTimeField(editable=False, null=True, blank=True)
finished_on = models.DateTimeField(editable=False, null=True, blank=True)

# TODO: Should be many to many relation with roles
raw_data = models.ForeignKey(
"datasets.Dataset", editable=False, null=True, blank=False, on_delete=models.PROTECT, related_name="+"
)
project = models.ForeignKey(Project, editable=False, null=True, blank=False, on_delete=models.PROTECT)

objects = JobManager()

class Meta:
verbose_name = "Job"
2 changes: 1 addition & 1 deletion batch/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def get_viewer_url(self, obj: models.Job) -> Optional[str]:

class Meta:
model = models.Job
fields = ["id", "status", "created_on", "viewer_url"]
fields = ["id", "status", "created_on", "started_on", "finished_on", "viewer_url"]


class BatchJob(serializers.Serializer):
Expand Down
30 changes: 20 additions & 10 deletions batch/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,17 @@ def post(self, request, *args, **kwargs):
project = self.get_object()

try:
dataset = self._compatible_datasets.get(pk=int(self.request.POST["dataset"]))
except (KeyError, ValueError, datasets_models.Dataset.DoesNotExist):
prefix = "dataset-"
# Is it an error if there are no datasets, or some datasets in the request are not in the compatible set?
datasets = self._compatible_datasets.filter(
id__in=(int(k[len(prefix):]) for k in self.request.POST if k.startswith(prefix))
)
except (KeyError, ValueError):
return HttpResponse(status=400)

models.Job.objects.create(project=project, owner=self.request.user, raw_data=dataset)
models.Job.objects.bulk_create(
models.Job(project=project, owner=self.request.user, raw_data=dataset) for dataset in datasets
)
return HttpResponseRedirect(urls.reverse("batch:project-detail", args=[project.pk]))

@property
Expand Down Expand Up @@ -127,19 +133,23 @@ def update(self, request, external_id: str):
serializer = serializers.JobUpdate(data=request.data)
serializer.is_valid(raise_exception=True)

status = serializer.data["status"]

job = get_object_or_404(models.Job, external_id=external_id)
job.status = status
job.save()
job_fields = {"external_id": external_id}

if status == "done":
try:
status = models.JobStatus(serializer.data["status"])
n_affected = models.Job.objects.filter(**job_fields).update_status(old=models.JobStatus.running, new=status)
if n_affected != 1:
raise ValueError
except ValueError:
return response.Response(status=400)

if status == models.JobStatus.done:
dataset_params = {
"name": serializer.data["name"],
"url": serializer.data["result_url"],
"dtype": serializer.data["dtype"],
**{k: v for k, v in serializer.data.items() if k.startswith("size_")},
"job": job,
"job": models.Job.objects.get(**job_fields),
}
datasets_models.Dataset(**dataset_params).save()

Expand Down
25 changes: 18 additions & 7 deletions cloud_ilastik/templates/batch/project_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
<table class="table" id="project-job-table" data-fetch-url="{% url "job-list" object.pk %}">
<thead>
<tr>
<th scope="row">#</th>
<th scope="col">ID</th>
<th scope="col">Status</th>
<th scope="col">Submission Time</th>
<th scope="col">Running Time</th>
<th scope="col">Result</th>
</tr>
</thead>
Expand All @@ -26,8 +26,9 @@
collected: "table-success",
};

const JobItem = ({index, id, status, created_on, viewer_url}) => {
const time = moment(created_on);
const JobItem = ({id, status, created_on, started_on, finished_on, viewer_url}) => {
const submissionTime = moment(created_on);

let resultElement = null;
if (viewer_url) {
resultElement = h('a', {
Expand All @@ -38,11 +39,21 @@
rel: 'noopener',
}, 'Open in New Tab');
}

let runningTimeElement = h('td', null);
if (started_on && finished_on) {
const startedTime = moment(started_on);
const finishedTime = moment(finished_on);
runningTimeElement = h('td', {
title: `Started: ${startedTime}\nFinished: ${finishedTime}`,
}, finishedTime.from(startedTime, true));
}

return h('tr', {key: id, class: statusClasses[status] || ''},
h('td', {scope: 'row'}, index + 1),
h('td', null, id.slice(0, 6)),
h('td', {title: id}, id.slice(0, 6)),
h('td', null, status),
h('td', {title: time.calendar()}, time.fromNow()),
h('td', {title: submissionTime}, submissionTime.fromNow()),
runningTimeElement,
h('td', null, resultElement),
);
};
Expand All @@ -68,7 +79,7 @@
}

render() {
const children = this.state.jobs.map((job, index) => JobItem({...job, index}));
const children = this.state.jobs.map(JobItem);
return h(Fragment, null, ...children);
}
}
Expand Down
5 changes: 2 additions & 3 deletions cloud_ilastik/templates/batch/project_new_job.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@
<legend>Select datasets for batch job submission</legend>
{% for dataset in dataset_list %}
<div class="form-check">
<input type="radio"
<input type="checkbox"
id="dataset-{{ dataset.pk }}"
name="dataset"
value="{{ dataset.pk }}"
name="dataset-{{ dataset.pk }}"
class="form-check-input">
<label for="dataset-{{ dataset.pk }}" class="form-check-label">
<a href="{% url 'datasets:detail' dataset.pk %}">{{ dataset.name }}</a>
Expand Down