Skip to content
Merged
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
34 changes: 34 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,37 @@ CustomHealthCheck ... Unavailable: Something went wrong!

Similar to the http version, a critical error will cause the command to
quit with the exit code `1`.

## Performance tweaks

All checks are executed asynchronously, either via `asyncio` or via a thread pool,
depending on the implementation of the individual checks.
This allows for concurrent execution of the IO-bound checks,
which reduces the response time.

The event loop's default executor is used to run synchronous checks
(e.g. [Database][health_check.checks.Database], [Mail][health_check.checks.Mail],
or [Storage][health_check.checks.Storage]) in a thread pool.
This pool is usually persisted across requests. This may lead to high performance while
permanently allocating more memory. This may be undesirable for some applications,
especially with `S3Storage`, which uses thread-local connections.

This can be mitigated by using a custom executor that creates a new
thread pool for each request, which is then cleaned up after the checks
are completed. This can be achieved by subclassing `HealthCheckView`
and overriding the `get_executor` method to return a context manager
providing a new `ThreadPoolExecutor` instance for each request.

```python
from concurrent.futures import ThreadPoolExecutor
from health_check.views import HealthCheckView


class CustomHealthCheckView(HealthCheckView):
def get_executor(self):
return ThreadPoolExecutor(max_workers=len(self.checks))
```

This approach ensures that each request gets a fresh thread pool,
which can help manage memory usage more effectively
while still providing the benefits of concurrent execution for synchronous checks.
7 changes: 4 additions & 3 deletions health_check/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import inspect
import logging
import timeit
from concurrent.futures import Executor

from health_check.exceptions import HealthCheckException

Expand Down Expand Up @@ -70,16 +71,16 @@ async def run(self) -> None:
...

def pretty_status(self) -> str:
"""Return human-readable status string, always 'OK' for the check itself."""
"""Return a human-readable status string, always 'OK' for the check itself."""
return "OK"

async def get_result(self: HealthCheck) -> HealthCheckResult:
async def get_result(self, executor: Executor | None = None) -> HealthCheckResult:
loop = asyncio.get_running_loop()
start = timeit.default_timer()
try:
await self.run() if inspect.iscoroutinefunction(
self.run
) else await loop.run_in_executor(None, self.run)
) else await loop.run_in_executor(executor, self.run)
Comment thread
codingjoe marked this conversation as resolved.
except HealthCheckException as e:
error = e
except BaseException:
Expand Down
6 changes: 3 additions & 3 deletions health_check/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,11 +194,11 @@ async def run(self):
@dataclasses.dataclass
class Mail(HealthCheck):
"""
Check that mail backend is able to open and close connection.
Check that an email backend is able to open and close the connection.

Args:
backend: The email backend to test against.
timeout: Timeout for connection to mail server in seconds.
timeout: Timeout for connection to an email server in seconds.

"""

Expand Down Expand Up @@ -231,7 +231,7 @@ class Storage(HealthCheck):
"""
Check file storage backends by saving, reading, and deleting a test file.

It can be setup multiple times for different storage backends if needed.
It can be set up multiple times for different storage backends if needed.

Args:
alias: The alias of the storage backend to check.
Expand Down
25 changes: 21 additions & 4 deletions health_check/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import asyncio
import contextlib
import datetime
import re
import typing
from concurrent.futures import Executor

from django.db import transaction
from django.http import HttpResponse, JsonResponse
Expand Down Expand Up @@ -111,9 +113,10 @@ async def dispatch(self, request, *args, **kwargs):

@method_decorator(never_cache)
async def get(self, request, *args, **kwargs):
self.results = await asyncio.gather(
*(check.get_result() for check in self.get_checks())
)
with self.get_executor() as executor:
self.results = await asyncio.gather(
*(check.get_result(executor) for check in self.get_checks())
)
has_errors = any(result.error for result in self.results)
status_code = 500 if has_errors else 200
format_override = request.GET.get("format")
Expand Down Expand Up @@ -159,6 +162,20 @@ def get_context_data(self, **kwargs):
"errors": any(result.error for result in self.results),
}

def get_executor(self) -> contextlib.AbstractContextManager[Executor | None]:
"""
Return a context manager providing an executor for synchronous checks.

Return a context manager that yields ``None`` to use the event loop's
default executor.

Example:
def get_executor(self):
return ThreadPoolExecutor(max_workers=5)

"""
return contextlib.nullcontext(None)

def render_to_response_json(self, status):
"""Return JSON response with health check results."""
return JsonResponse(
Expand All @@ -170,7 +187,7 @@ def render_to_response_json(self, status):
)

def render_to_response_text(self, status):
"""Return plain text response with health check results."""
"""Return a plain text response with health check results."""
lines = (
f"{repr(result.check)}: {'OK' if not result.error else str(result.error)}"
for result in self.results
Expand Down
2 changes: 1 addition & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ plugins:
- https://www.psycopg.org/psycopg3/docs/objects.inv
- https://docs.celeryq.dev/en/stable/objects.inv
- https://psutil.readthedocs.io/en/stable/objects.inv
- https://django-storages.readthedocs.io/en/latest/objects.inv
- https://redis.readthedocs.io/en/stable/objects.inv
- https://django-storages.readthedocs.io/en/stable/objects.inv
theme:
name: material
logo: images/icon.svg
Expand Down
33 changes: 33 additions & 0 deletions tests/test_base.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import asyncio
from unittest.mock import MagicMock, patch

import pytest

from health_check.base import HealthCheck, HealthCheckResult
Expand Down Expand Up @@ -46,6 +49,36 @@ def run(self):
assert result.error is None
assert isinstance(result, HealthCheckResult)

@pytest.mark.asyncio
async def test_run__sync_check_uses_custom_executor(self):
"""Pass custom executor to run_in_executor for synchronous checks."""

class SyncCheck(HealthCheck):
def run(self):
pass

check = SyncCheck()
custom_executor = MagicMock()
loop = asyncio.get_running_loop()
with patch.object(loop, "run_in_executor", wraps=loop.run_in_executor) as mock:
await check.get_result(executor=custom_executor)
mock.assert_called_once_with(custom_executor, check.run)

@pytest.mark.asyncio
async def test_run__sync_check_default_executor(self):
"""Use default executor (None) for synchronous checks when none is supplied."""

class SyncCheck(HealthCheck):
def run(self):
pass

check = SyncCheck()
loop = asyncio.get_running_loop()
with patch.object(loop, "run_in_executor", wraps=loop.run_in_executor) as mock:
result = await check.get_result()
mock.assert_called_once_with(None, check.run)
assert result.error is None

@pytest.mark.asyncio
async def test_result__timing(self):
"""Result includes execution time."""
Expand Down
33 changes: 33 additions & 0 deletions tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1038,3 +1038,36 @@ async def run(self):
assert ": OK" in content
assert "FailingBackend" in content
assert "Failed" in content

def test_get_executor__returns_null_context(self):
"""Default get_executor returns a nullcontext yielding None."""
import contextlib

view = HealthCheckView()
ctx = view.get_executor()
assert isinstance(ctx, contextlib.AbstractContextManager)
with ctx as executor:
assert executor is None

@pytest.mark.asyncio
async def test_get_executor__custom_thread_pool(self):
"""Custom get_executor returning a ThreadPoolExecutor is used for sync checks."""
from concurrent.futures import ThreadPoolExecutor

from django.test import AsyncRequestFactory

class CustomHealthCheckView(HealthCheckView):
def get_executor(self):
return ThreadPoolExecutor(max_workers=1)

class SyncCheck(HealthCheck):
def run(self):
pass

factory = AsyncRequestFactory()
request = factory.get("/")
view = CustomHealthCheckView.as_view(checks=[SyncCheck])
response = await view(request)
if hasattr(response, "render"):
response.render()
assert response.status_code == 200
Loading