Skip to content

Commit d5cac96

Browse files
committed
chore: improve startup and shutdown for background tasks
1 parent 6f1b960 commit d5cac96

8 files changed

Lines changed: 122 additions & 2 deletions

File tree

‎backend/application/background_tasks/management/__init__.py‎

Whitespace-only changes.

‎backend/application/background_tasks/management/commands/__init__.py‎

Whitespace-only changes.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import logging
2+
from typing import Any
3+
4+
from django.core.management.base import BaseCommand
5+
from huey.contrib.djhuey import HUEY as huey
6+
from huey.contrib.stats import HueyInflight
7+
8+
logger = logging.getLogger("secobserve.background_tasks")
9+
10+
11+
class Command(BaseCommand):
12+
help = "Delete stale in-flight entries of the Huey statistics, left over from an ungraceful shutdown."
13+
14+
def handle(self, *args: Any, **options: Any) -> None:
15+
# The consumer and the web server share one container and there is only one backend
16+
# container per pod, so nothing of this queue can legitimately be in flight during startup.
17+
# Entries still present are leftovers of tasks that never emitted a terminal signal, for
18+
# example because the container was killed while they were executing. Neither the huey
19+
# consumer nor the statistics themselves clean them up, so they would be reported as
20+
# running for hours by the background task statistics.
21+
if getattr(huey, "_stats", None) is None:
22+
logger.info("Huey statistics are not enabled, no in-flight entries to flush")
23+
return
24+
25+
deleted = HueyInflight.delete().where(HueyInflight.queue == huey.name).execute()
26+
logger.info("Flushed %s stale in-flight entries of queue %s", deleted, huey.name)

‎backend/unittests/background_tasks/management/__init__.py‎

Whitespace-only changes.

‎backend/unittests/background_tasks/management/commands/__init__.py‎

Whitespace-only changes.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import peewee
4+
from django.core.management import call_command
5+
from django.test import TestCase
6+
from huey.contrib.stats import HueyInflight
7+
8+
COMMAND = "flush_huey_inflight"
9+
MODULE = "application.background_tasks.management.commands.flush_huey_inflight"
10+
11+
12+
class TestFlushHueyInflightCommand(TestCase):
13+
"""Tests for the `flush_huey_inflight` management command.
14+
15+
The command deletes the entries of the `huey_inflight` table for its own queue. They are
16+
written by the Huey statistics when a task starts and are only deleted when the task emits a
17+
terminal signal, which does not happen when the container is killed while the task is running.
18+
"""
19+
20+
def setUp(self) -> None:
21+
# The Huey statistics use peewee, not the Django ORM, so the table cannot be created by a
22+
# Django migration and has to be set up here.
23+
self.database = peewee.SqliteDatabase(":memory:")
24+
self.bind_ctx = self.database.bind_ctx([HueyInflight])
25+
self.bind_ctx.__enter__()
26+
self.database.create_tables([HueyInflight])
27+
28+
self.huey = MagicMock()
29+
self.huey.name = "secobserve"
30+
self.huey._stats = MagicMock()
31+
32+
def tearDown(self) -> None:
33+
self.database.drop_tables([HueyInflight])
34+
self.bind_ctx.__exit__(None, None, None)
35+
self.database.close()
36+
37+
def test_stale_entries_of_own_queue_are_deleted(self) -> None:
38+
HueyInflight.create(task_id="task_1", queue="secobserve", task="module.Task_1", started=1000.0)
39+
HueyInflight.create(task_id="task_2", queue="secobserve", task="module.Task_2", started=2000.0)
40+
HueyInflight.create(task_id="task_3", queue="other_queue", task="module.Task_3", started=3000.0)
41+
42+
with patch(f"{MODULE}.huey", self.huey):
43+
call_command(COMMAND)
44+
45+
remaining = [entry.task_id for entry in HueyInflight.select()]
46+
self.assertEqual(["task_3"], remaining)
47+
48+
def test_no_entries_is_not_an_error(self) -> None:
49+
with patch(f"{MODULE}.huey", self.huey):
50+
call_command(COMMAND)
51+
52+
self.assertEqual(0, HueyInflight.select().count())
53+
54+
def test_nothing_is_deleted_when_statistics_are_disabled(self) -> None:
55+
HueyInflight.create(task_id="task_1", queue="secobserve", task="module.Task_1", started=1000.0)
56+
self.huey._stats = None
57+
58+
with patch(f"{MODULE}.huey", self.huey):
59+
call_command(COMMAND)
60+
61+
self.assertEqual(1, HueyInflight.select().count())

‎docker/backend/dev/django/entrypoint‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,26 @@ User.objects.create_superuser(
4040
EOD
4141
fi
4242

43+
python manage.py flush_huey_inflight
4344
python manage.py run_huey --flush-locks &
45+
HUEY_PID=$!
46+
RUNSERVER_PID=""
47+
48+
# This script is PID 1 of the container, but a non-interactive shell does not forward signals to
49+
# its children. Without the trap neither the development server nor the huey consumer would ever
50+
# see the SIGTERM of a container shutdown and both would be killed after the grace period. The huey
51+
# consumer needs the SIGTERM to finish its running tasks and to mark them as no longer in flight.
52+
trap 'kill -TERM $HUEY_PID $RUNSERVER_PID 2>/dev/null' TERM INT
4453

4554
python manage.py register_parsers
4655
python manage.py initial_license_load
4756

48-
python manage.py runserver_plus 0.0.0.0:8000
57+
python manage.py runserver_plus 0.0.0.0:8000 &
58+
RUNSERVER_PID=$!
59+
60+
# "wait" returns a non-zero exit code when it is interrupted by a signal, which would abort the
61+
# script because of "set -o errexit". Waiting for the huey consumer after the development server
62+
# has terminated gives it the chance to shut down gracefully before this script and with it the
63+
# container exits.
64+
wait "$RUNSERVER_PID" || true
65+
wait "$HUEY_PID" || true

‎docker/backend/prod/django/entrypoint‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,16 @@ fi
4646

4747
python /app/manage.py collectstatic --noinput
4848

49+
python manage.py flush_huey_inflight
4950
python manage.py run_huey --flush-locks &
51+
HUEY_PID=$!
52+
GUNICORN_PID=""
53+
54+
# This script is PID 1 of the container, but a non-interactive shell does not forward signals to
55+
# its children. Without the trap neither gunicorn nor the huey consumer would ever see the SIGTERM
56+
# of a container shutdown and both would be killed after the grace period. The huey consumer needs
57+
# the SIGTERM to finish its running tasks and to mark them as no longer in flight.
58+
trap 'kill -TERM $HUEY_PID $GUNICORN_PID 2>/dev/null' TERM INT
5059

5160
python manage.py register_parsers
5261
python manage.py initial_license_load
@@ -66,4 +75,11 @@ if [ -z "${GUNICORN_LIMIT_REQUEST_FIELD_SIZE}" ]; then
6675
GUNICORN_LIMIT_REQUEST_FIELD_SIZE=16380
6776
fi
6877

69-
gunicorn config.wsgi --no-control-socket --bind 0.0.0.0:5000 --timeout 1200 --chdir=/app -w $GUNICORN_WORKERS -k gthread --threads $GUNICORN_THREADS --limit-request-field_size $GUNICORN_LIMIT_REQUEST_FIELD_SIZE
78+
gunicorn config.wsgi --no-control-socket --bind 0.0.0.0:5000 --timeout 1200 --chdir=/app -w $GUNICORN_WORKERS -k gthread --threads $GUNICORN_THREADS --limit-request-field_size $GUNICORN_LIMIT_REQUEST_FIELD_SIZE &
79+
GUNICORN_PID=$!
80+
81+
# "wait" returns a non-zero exit code when it is interrupted by a signal, which would abort the
82+
# script because of "set -o errexit". Waiting for the huey consumer after gunicorn has terminated
83+
# gives it the chance to shut down gracefully before this script and with it the container exits.
84+
wait "$GUNICORN_PID" || true
85+
wait "$HUEY_PID" || true

0 commit comments

Comments
 (0)