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

feat: async investigate_fragment task; celery results backend #8428

Merged
merged 9 commits into from
Jan 17, 2025
Merged
Show file tree
Hide file tree
Changes from 7 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
1 change: 1 addition & 0 deletions ietf/doc/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ class InvestigateForm(forms.Form):
),
min_length=8,
)
task_id = forms.CharField(required=False, widget=forms.HiddenInput)

def clean_name_fragment(self):
disallowed_characters = ["%", "/", "\\", "*"]
Expand Down
9 changes: 9 additions & 0 deletions ietf/doc/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
generate_idnits2_rfcs_obsoleted,
update_or_create_draft_bibxml_file,
ensure_draft_bibxml_path_exists,
investigate_fragment,
)


Expand Down Expand Up @@ -119,3 +120,11 @@ def generate_draft_bibxml_files_task(days=7, process_all=False):
update_or_create_draft_bibxml_file(event.doc, event.rev)
except Exception as err:
log.log(f"Error generating bibxml for {event.doc.name}-{event.rev}: {err}")


@shared_task(ignore_result=False)
def investigate_fragment_task(name_fragment: str):
return {
"name_fragment": name_fragment,
"results": investigate_fragment(name_fragment),
}
13 changes: 13 additions & 0 deletions ietf/doc/tests_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
generate_draft_bibxml_files_task,
generate_idnits2_rfcs_obsoleted_task,
generate_idnits2_rfc_status_task,
investigate_fragment_task,
notify_expirations_task,
)

Expand Down Expand Up @@ -98,6 +99,18 @@ def test_expire_last_calls_task(self, mock_get_expired, mock_expire):
self.assertEqual(mock_expire.call_args_list[1], mock.call(docs[1]))
self.assertEqual(mock_expire.call_args_list[2], mock.call(docs[2]))

def test_investigate_fragment_task(self):
investigation_results = object() # singleton
with mock.patch(
"ietf.doc.tasks.investigate_fragment", return_value=investigation_results
) as mock_inv:
retval = investigate_fragment_task("some fragment")
self.assertTrue(mock_inv.called)
self.assertEqual(mock_inv.call_args, mock.call("some fragment"))
self.assertEqual(
retval, {"name_fragment": "some fragment", "results": investigation_results}
)


class Idnits2SupportTests(TestCase):
settings_temp_path_overrides = TestCase.settings_temp_path_overrides + ['DERIVED_DIR']
Expand Down
65 changes: 59 additions & 6 deletions ietf/doc/views_doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@

from pathlib import Path

from celery.result import AsyncResult
from django.core.cache import caches
from django.core.exceptions import PermissionDenied
from django.db.models import Max
from django.http import HttpResponse, Http404, HttpResponseBadRequest
from django.http import HttpResponse, Http404, HttpResponseBadRequest, JsonResponse
from django.shortcuts import render, get_object_or_404, redirect
from django.template.loader import render_to_string
from django.urls import reverse as urlreverse
Expand All @@ -59,8 +60,9 @@
ConsensusDocEvent, NewRevisionDocEvent, TelechatDocEvent, WriteupDocEvent, IanaExpertDocEvent,
IESG_BALLOT_ACTIVE_STATES, STATUSCHANGE_RELATIONS, DocumentActionHolder, DocumentAuthor,
RelatedDocument, RelatedDocHistory)
from ietf.doc.tasks import investigate_fragment_task
from ietf.doc.utils import (augment_events_with_revision,
can_adopt_draft, can_unadopt_draft, get_chartering_type, get_tags_for_stream_id, investigate_fragment,
can_adopt_draft, can_unadopt_draft, get_chartering_type, get_tags_for_stream_id,
needed_ballot_positions, nice_consensus, update_telechat, has_same_ballot,
get_initial_notify, make_notify_changed_event, make_rev_history, default_consensus,
add_events_message_info, get_unicode_document_content,
Expand Down Expand Up @@ -2275,16 +2277,67 @@ def idnits2_state(request, name, rev=None):
content_type="text/plain;charset=utf-8",
)


@role_required("Secretariat")
def investigate(request):
"""Investigate a fragment

A plain GET with no querystring returns the UI page.

POST with the task_id field empty starts an async task and returns a JSON response with
the ID needed to monitor the task for results.

GET with a querystring parameter "id" will poll the status of the async task and return "ready"
or "notready".

POST with the task_id field set to the id of a "ready" task will return its results or an error
if the task failed or the id is invalid (expired, never exited, etc).
"""
results = None
# Start an investigation or retrieve a result on a POST
if request.method == "POST":
form = InvestigateForm(request.POST)
if form.is_valid():
name_fragment = form.cleaned_data["name_fragment"]
results = investigate_fragment(name_fragment)
task_id = form.cleaned_data["task_id"]
if task_id:
# Ignore the rest of the form and retrieve the result
task_result = AsyncResult(task_id)
if task_result.successful():
retval = task_result.get()
results = retval["results"]
form.data = form.data.copy()
form.data["name_fragment"] = retval[
"name_fragment"
] # ensure consistency
del form.data["task_id"] # do not request the task result again
else:
form.add_error(
None,
"The investigation task failed. Please try again and ask for help if this recurs.",
)
# Falls through to the render at the end!
else:
name_fragment = form.cleaned_data["name_fragment"]
task_result = investigate_fragment_task.delay(name_fragment)
return JsonResponse({"id": task_result.id})
else:
form = InvestigateForm()
task_id = request.GET.get("id", None)
if task_id is not None:
# Check status if we got the "id" parameter
task_result = AsyncResult(task_id)
return JsonResponse(
{"status": "ready" if task_result.ready() else "notready"}
)
else:
# Serve up an empty form
form = InvestigateForm()

# If we get here, it is just a plain GET - serve the UI
return render(
request, "doc/investigate.html", context=dict(form=form, results=results)
request,
"doc/investigate.html",
context={
"form": form,
"results": results,
},
)
15 changes: 14 additions & 1 deletion ietf/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ def skip_unreadable_post(record):
'django_vite',
'django_bootstrap5',
'django_celery_beat',
'django_celery_results',
'corsheaders',
'django_markup',
'oidc_provider',
Expand Down Expand Up @@ -1226,7 +1227,9 @@ def skip_unreadable_post(record):
# https://docs.celeryq.dev/en/stable/userguide/tasks.html#rpc-result-backend-rabbitmq-qpid
# Results can be retrieved only once and only by the caller of the task. Results will be
# lost if the message broker restarts.
CELERY_RESULT_BACKEND = 'rpc://' # sends a msg via the msg broker
CELERY_RESULT_BACKEND = 'django-cache' # use a Django cache for results
CELERY_CACHE_BACKEND = 'celery-results' # which Django cache to use
CELERY_RESULT_EXPIRES = datetime.timedelta(minutes=5) # how long are results valid? (Default is 1 day)
CELERY_TASK_IGNORE_RESULT = True # ignore results unless specifically enabled for a task

# Meetecho API setup: Uncomment this and provide real credentials to enable
Expand Down Expand Up @@ -1309,6 +1312,11 @@ def skip_unreadable_post(record):
"MAX_ENTRIES": 5000,
},
},
"celery-results": {
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": f"{MEMCACHED_HOST}:{MEMCACHED_PORT}",
"KEY_PREFIX": "ietf:celery",
},
}
else:
CACHES = {
Expand Down Expand Up @@ -1347,6 +1355,11 @@ def skip_unreadable_post(record):
"MAX_ENTRIES": 5000,
},
},
"celery-results": {
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": "app:11211",
"KEY_PREFIX": "ietf:celery",
},
}

PUBLISH_IPR_STATES = ['posted', 'removed', 'removed_objfalse']
Expand Down
53 changes: 53 additions & 0 deletions ietf/static/js/investigate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright The IETF Trust 2025, All Rights Reserved
document.addEventListener('DOMContentLoaded', () => {
const investigateForm = document.forms['investigate']
investigateForm.addEventListener('submit', (event) => {
// Intercept submission unless we've filled in the task_id field
if (!investigateForm.elements['id_task_id'].value) {
event.preventDefault()
runInvestigation()
}
})

const runInvestigation = async () => {
// Submit the request
const response = await fetch('', {
method: investigateForm.method, body: new FormData(investigateForm)
})
if (!response.ok) {
loadResultsFromTask('bogus-task-id') // bad task id will generate an error from Django
}
const taskId = (await response.json()).id
// Poll for completion of the investigation up to 18*10 = 180 seconds
waitForResults(taskId, 18)
}

const waitForResults = async (taskId, retries) => {
// indicate that investigation is in progress
document.getElementById('spinner').classList.remove('d-none')
document.getElementById('investigate-button').disabled = true
investigateForm.elements['id_name_fragment'].disabled = true

const response = await fetch('?' + new URLSearchParams({ id: taskId }))
if (!response.ok) {
loadResultsFromTask('bogus-task-id') // bad task id will generate an error from Django
}
const result = await response.json()
if (result.status !== 'ready' && retries > 0) {
// 10 seconds per retry
setTimeout(waitForResults, 10000, taskId, retries - 1)
} else {
/* Either the response is ready or we timed out waiting. In either case, submit
the task_id via POST and let Django display an error if it's not ready. Before
submitting, re-enable the form fields so the POST is valid. Other in-progress
indicators will be reset when the POST response is loaded. */
loadResultsFromTask(taskId)
}
}

const loadResultsFromTask = (taskId) => {
investigateForm.elements['id_name_fragment'].disabled = false
investigateForm.elements['id_task_id'].value = taskId
investigateForm.submit()
}
})
Loading
Loading