Skip to content

Commit 3c4e201

Browse files
committed
Ref #701 -- Add support for a custom executor for sychronous checks
1 parent 3decb56 commit 3c4e201

File tree

5 files changed

+62
-10
lines changed

5 files changed

+62
-10
lines changed

docs/usage.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,35 @@ CustomHealthCheck ... Unavailable: Something went wrong!
141141

142142
Similar to the http version, a critical error will cause the command to
143143
quit with the exit code `1`.
144+
145+
## Performance tweaks
146+
147+
All checks are executed asynchronously, either via `asyncio` or via a thread pool,
148+
depending on the implementation of the individual checks.
149+
This allows for concurrent execution of the mostly IO-bound checks,
150+
which significantly improves the response time.
151+
152+
The event loop's default executor is used to run synchronous checks
153+
(e.g. [Database][health_check.checks.Database], [Mail][health_check.checks.Mail],
154+
or [Storage][health_check.checks.Storage]) in a thread pool.
155+
This pool is usually persisted across requests. This may lead to high performance while
156+
permanently allocating more memory. This may be undesirable for some applications,
157+
especially with `S3Storage`, which uses thread-local connections.
158+
159+
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 new `ThreadPoolExecutor` instance for each request.
160+
161+
```python
162+
from concurrent.futures import ThreadPoolExecutor
163+
from health_check.views import HealthCheckView
164+
165+
166+
class CustomHealthCheckView(HealthCheckView):
167+
@staticmethod
168+
def get_executor(self):
169+
with ThreadPoolExecutor(max_workers=len(self.checks)) as executor:
170+
yield executor
171+
```
172+
173+
This approach ensures that each request gets a fresh thread pool,
174+
which can help manage memory usage more effectively
175+
while still providing the benefits of concurrent execution for synchronous checks.

health_check/base.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import inspect
77
import logging
88
import timeit
9+
from concurrent.futures import Executor
910

1011
from health_check.exceptions import HealthCheckException
1112

@@ -70,16 +71,16 @@ async def run(self) -> None:
7071
...
7172

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

76-
async def get_result(self: HealthCheck) -> HealthCheckResult:
77+
async def get_result(self, executor: Executor | None = None) -> HealthCheckResult:
7778
loop = asyncio.get_running_loop()
7879
start = timeit.default_timer()
7980
try:
8081
await self.run() if inspect.iscoroutinefunction(
8182
self.run
82-
) else await loop.run_in_executor(None, self.run)
83+
) else await loop.run_in_executor(executor, self.run)
8384
except HealthCheckException as e:
8485
error = e
8586
except BaseException:

health_check/checks.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,11 @@ async def run(self):
194194
@dataclasses.dataclass
195195
class Mail(HealthCheck):
196196
"""
197-
Check that mail backend is able to open and close connection.
197+
Check that an email backend is able to open and close the connection.
198198
199199
Args:
200200
backend: The email backend to test against.
201-
timeout: Timeout for connection to mail server in seconds.
201+
timeout: Timeout for connection to an email server in seconds.
202202
203203
"""
204204

@@ -231,7 +231,7 @@ class Storage(HealthCheck):
231231
"""
232232
Check file storage backends by saving, reading, and deleting a test file.
233233
234-
It can be setup multiple times for different storage backends if needed.
234+
It can be set up multiple times for different storage backends if needed.
235235
236236
Args:
237237
alias: The alias of the storage backend to check.

health_check/views.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import asyncio
2+
import contextlib
23
import datetime
34
import re
45
import typing
6+
from concurrent.futures import Executor
57

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

112114
@method_decorator(never_cache)
113115
async def get(self, request, *args, **kwargs):
114-
self.results = await asyncio.gather(
115-
*(check.get_result() for check in self.get_checks())
116-
)
116+
with contextlib.contextmanager(self.get_executor)() as executor:
117+
self.results = await asyncio.gather(
118+
*(check.get_result(executor) for check in self.get_checks())
119+
)
117120
has_errors = any(result.error for result in self.results)
118121
status_code = 500 if has_errors else 200
119122
format_override = request.GET.get("format")
@@ -159,6 +162,21 @@ def get_context_data(self, **kwargs):
159162
"errors": any(result.error for result in self.results),
160163
}
161164

165+
def get_executor(self) -> typing.Generator[Executor | None, None, None]:
166+
"""
167+
Yield an executor to run synchronous checks.
168+
169+
Yield None to use the event loop's default executor.
170+
171+
Example:
172+
@staticmethod
173+
def get_executor():
174+
with ThreadPoolExecutor(max_workers=5) as executor:
175+
yield executor
176+
177+
"""
178+
yield None
179+
162180
def render_to_response_json(self, status):
163181
"""Return JSON response with health check results."""
164182
return JsonResponse(
@@ -170,7 +188,7 @@ def render_to_response_json(self, status):
170188
)
171189

172190
def render_to_response_text(self, status):
173-
"""Return plain text response with health check results."""
191+
"""Return a plain text response with health check results."""
174192
lines = (
175193
f"{repr(result.check)}: {'OK' if not result.error else str(result.error)}"
176194
for result in self.results

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ plugins:
3232
- https://psutil.readthedocs.io/en/stable/objects.inv
3333
- https://django-storages.readthedocs.io/en/latest/objects.inv
3434
- https://redis.readthedocs.io/en/stable/objects.inv
35+
- https://django-storages.readthedocs.io/en/stable/objects.inv
3536
theme:
3637
name: material
3738
logo: images/icon.svg

0 commit comments

Comments
 (0)