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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`ox_health --queue`, so queues with different retention needs can each
be pruned with their own `--older-than`. Old schedule ticks are still
pruned for every schedule.
- `ox_health --format json` prints the check figures as one JSON object
for container healthchecks and monitoring agents. On a failing check the
object is still printed, and the exit status is unchanged.

### Changed

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ python manage.py ox_health --max-backlog 1000 --max-age 600
| Flag | Default | Meaning |
| --- | --- | --- |
| `--queue` | all queues | Restrict the checks to one queue. |
| `--format` | `text` | `json` prints one object on stdout instead of the `OK:` line: `ok`, `queue`, `backlog`, `oldest_age_seconds`, `last_claim_age_seconds` and `problems`. `queue` is `null` when no `--queue` is given. The figures are `null` when there is nothing to measure or the check could not run, as with an unreachable database or an invalid threshold. The object is printed on failure too, before the same non-zero exit. |
| `--max-backlog` | off | Fail when more than this many READY tasks are eligible to run. |
| `--max-age` | off | Fail when the oldest waiting task has waited longer than this. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. |
| `--worker-timeout` | off | Fail when no worker has claimed a task within this long. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. |
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ python manage.py ox_health --max-backlog 1000 --max-age 600
| Flag | Default | Meaning |
| --- | --- | --- |
| `--queue` | all queues | Restrict the checks to one queue. |
| `--format` | `text` | `json` prints one object on stdout instead of the `OK:` line: `ok`, `queue`, `backlog`, `oldest_age_seconds`, `last_claim_age_seconds` and `problems`. `queue` is `null` when no `--queue` is given. The figures are `null` when there is nothing to measure or the check could not run, as with an unreachable database or an invalid threshold. The object is printed on failure too, before the same non-zero exit. |
| `--max-backlog` | off | Fail when more than this many READY tasks are eligible to run. Tasks deferred to a future `run_after` do not count. |
| `--max-age` | off | Fail when the oldest waiting task has waited longer than this since becoming eligible. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. |
| `--worker-timeout` | off | Fail when no worker has claimed a task within this long, or no claim was ever recorded. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. |
Expand Down
5 changes: 5 additions & 0 deletions docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ python manage.py ox_health --max-backlog 1000 --max-age 600
| Flag | Default | Meaning |
| --- | --- | --- |
| `--queue` | all queues | Restrict the checks to one queue. |
| `--format` | `text` | `json` prints one object on stdout instead of the `OK:` line: `ok`, `queue`, `backlog`, `oldest_age_seconds`, `last_claim_age_seconds` and `problems`. `queue` is `null` when no `--queue` is given. The figures are `null` when there is nothing to measure or the check could not run, as with an unreachable database or an invalid threshold. The object is printed on failure too, before the same non-zero exit. |
| `--max-backlog` | off | Fail when more than this many READY tasks are eligible to run. Tasks deferred to a future `run_after` do not count. |
| `--max-age` | off | Fail when the oldest waiting task has waited longer than this since becoming eligible. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. |
| `--worker-timeout` | off | Fail when no worker has claimed a task within this long, or no claim was ever recorded. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. |
Expand Down Expand Up @@ -2482,6 +2483,7 @@ python manage.py ox_health --max-backlog 1000 --max-age 600
| Flag | Default | Meaning |
| --- | --- | --- |
| `--queue` | all queues | Restrict the checks to one queue. |
| `--format` | `text` | `json` prints one object on stdout instead of the `OK:` line: `ok`, `queue`, `backlog`, `oldest_age_seconds`, `last_claim_age_seconds` and `problems`. `queue` is `null` when no `--queue` is given. The figures are `null` when there is nothing to measure or the check could not run, as with an unreachable database or an invalid threshold. The object is printed on failure too, before the same non-zero exit. |
| `--max-backlog` | off | Fail when more than this many READY tasks are eligible to run. Deferred tasks do not count. |
| `--max-age` | off | Fail when the oldest waiting task has waited longer than this since becoming eligible. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. |
| `--worker-timeout` | off | Fail when no worker has claimed a task within this long, or no claim was ever recorded. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. |
Expand Down Expand Up @@ -3540,6 +3542,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`ox_health --queue`, so queues with different retention needs can each
be pruned with their own `--older-than`. Old schedule ticks are still
pruned for every schedule.
- `ox_health --format json` prints the check figures as one JSON object
for container healthchecks and monitoring agents. On a failing check the
object is still printed, and the exit status is unchanged.

### Changed

Expand Down
1 change: 1 addition & 0 deletions docs/monitoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ python manage.py ox_health --max-backlog 1000 --max-age 600
| Flag | Default | Meaning |
| --- | --- | --- |
| `--queue` | all queues | Restrict the checks to one queue. |
| `--format` | `text` | `json` prints one object on stdout instead of the `OK:` line: `ok`, `queue`, `backlog`, `oldest_age_seconds`, `last_claim_age_seconds` and `problems`. `queue` is `null` when no `--queue` is given. The figures are `null` when there is nothing to measure or the check could not run, as with an unreachable database or an invalid threshold. The object is printed on failure too, before the same non-zero exit. |
| `--max-backlog` | off | Fail when more than this many READY tasks are eligible to run. Deferred tasks do not count. |
| `--max-age` | off | Fail when the oldest waiting task has waited longer than this since becoming eligible. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. |
| `--worker-timeout` | off | Fail when no worker has claimed a task within this long, or no claim was ever recorded. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. |
Expand Down
64 changes: 58 additions & 6 deletions src/django_ox/management/commands/ox_health.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
from datetime import timedelta
from typing import Any
from typing import Any, NoReturn

from django.core.management.base import BaseCommand, CommandError, CommandParser
from django.db import DatabaseError
Expand All @@ -12,6 +13,10 @@ def _seconds(value: timedelta | None) -> str:
return "none" if value is None else f"{value.total_seconds():.0f}s"


def _total_seconds(value: timedelta | None) -> float | None:
return None if value is None else value.total_seconds()


class Command(BaseCommand):
help = (
"Check queue health. Exits 0 when every enabled check passes, "
Expand All @@ -20,6 +25,16 @@ class Command(BaseCommand):
)

def add_arguments(self, parser: CommandParser) -> None:
parser.add_argument(
"--format",
choices=["text", "json"],
default="text",
help=(
"Output format. json prints one object with the same figures "
"on stdout, also when a check fails; the exit status is the "
"same either way (default: %(default)s)."
),
)
parser.add_argument(
"--queue",
default=None,
Expand Down Expand Up @@ -63,20 +78,30 @@ def handle(self, *args: Any, **options: Any) -> None:
max_backlog: int | None = options["max_backlog"]
max_age: float | None = options["max_age"]
worker_timeout: float | None = options["worker_timeout"]
queue: str | None = options["queue"]
as_json = options["format"] == "json"

def _invalid(reason: str) -> NoReturn:
if as_json:
self._write_json(queue, None, None, None, [reason])
raise CommandError(reason)

if max_backlog is not None and max_backlog < 0:
raise CommandError("--max-backlog must be zero or a positive integer.")
_invalid("--max-backlog must be zero or a positive integer.")
if max_age is not None and max_age <= 0:
raise CommandError("--max-age must be a positive number of seconds.")
_invalid("--max-age must be a positive number of seconds.")
if worker_timeout is not None and worker_timeout <= 0:
raise CommandError("--worker-timeout must be a positive number of seconds.")
_invalid("--worker-timeout must be a positive number of seconds.")

queue: str | None = options["queue"]
try:
backlog = stats.ready_count(queue)
oldest = stats.oldest_ready_age(queue)
claim_age = stats.last_claim_age(queue)
except DatabaseError as exc:
raise CommandError(f"Database unreachable: {exc}") from exc
reason = f"Database unreachable: {exc}"
if as_json:
self._write_json(queue, None, None, None, [reason])
raise CommandError(reason) from exc

problems: list[str] = []
if max_backlog is not None and backlog > max_backlog:
Expand All @@ -100,10 +125,37 @@ def handle(self, *args: Any, **options: Any) -> None:
f"last task claim was {_seconds(claim_age)} ago, "
f"over --worker-timeout {worker_timeout:g}s"
)
if as_json:
# A monitoring agent wants the figures most when a check fails, so
# the object is printed before the non-zero exit, not instead of it.
self._write_json(queue, backlog, oldest, claim_age, problems)
if problems:
raise CommandError("; ".join(problems))
if as_json:
return

self.stdout.write(
f"OK: backlog={backlog} oldest_age={_seconds(oldest)} "
f"last_claim_age={_seconds(claim_age)}"
)

def _write_json(
self,
queue: str | None,
backlog: int | None,
oldest: timedelta | None,
claim_age: timedelta | None,
problems: list[str],
) -> None:
self.stdout.write(
json.dumps(
{
"ok": not problems,
"queue": queue,
"backlog": backlog,
"oldest_age_seconds": _total_seconds(oldest),
"last_claim_age_seconds": _total_seconds(claim_age),
"problems": problems,
}
)
)
79 changes: 79 additions & 0 deletions tests/test_health.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import argparse
import json
from datetime import timedelta
from io import StringIO

Expand Down Expand Up @@ -47,12 +48,90 @@ def health(*args):
return out.getvalue()


def health_json(*args):
out = StringIO()
error = None
try:
call_command("ox_health", "--format", "json", *args, stdout=out)
except CommandError as exc:
error = exc
return json.loads(out.getvalue()), error


@pytest.mark.django_db
class TestHealth:
def test_ok_with_no_flags_on_empty_database(self):
out = health()
assert out.startswith("OK: backlog=0 oldest_age=none last_claim_age=none")

def test_json_ok_reports_the_same_figures(self):
make_ready(seconds_ago=30)
make_claimed(seconds_ago=60)

report, error = health_json("--max-backlog=5")

assert error is None
assert report["ok"] is True
assert report["queue"] is None
assert report["backlog"] == 1
assert report["oldest_age_seconds"] == pytest.approx(30, abs=5)
assert report["last_claim_age_seconds"] == pytest.approx(60, abs=5)
assert report["problems"] == []

def test_json_on_empty_database_uses_null_ages(self):
report, error = health_json("--queue", "emails")

assert error is None
assert report == {
"ok": True,
"queue": "emails",
"backlog": 0,
"oldest_age_seconds": None,
"last_claim_age_seconds": None,
"problems": [],
}

def test_json_failure_prints_the_object_and_exits_non_zero(self):
make_ready()
make_ready()

report, error = health_json("--max-backlog=1", "--worker-timeout=60")

assert isinstance(error, CommandError)
assert report["ok"] is False
assert report["backlog"] == 2
assert report["problems"] == [
"backlog is 2, over --max-backlog 1",
"no task claim recorded (--worker-timeout 60s)",
]
assert str(error) == "; ".join(report["problems"])

def test_json_database_unreachable_still_prints_the_object(self, monkeypatch):
def boom(queue_name=None):
raise DatabaseError("connection refused")

monkeypatch.setattr(ox_health.stats, "ready_count", boom)
report, error = health_json()

assert isinstance(error, CommandError)
assert report["ok"] is False
assert report["backlog"] is None
assert report["problems"] == ["Database unreachable: connection refused"]

def test_json_rejects_a_bad_threshold_but_still_prints_the_object(self):
report, error = health_json("--max-backlog=-1")

assert isinstance(error, CommandError)
assert report == {
"ok": False,
"queue": None,
"backlog": None,
"oldest_age_seconds": None,
"last_claim_age_seconds": None,
"problems": ["--max-backlog must be zero or a positive integer."],
}
assert str(error) == "--max-backlog must be zero or a positive integer."

def test_database_unreachable_fails_with_reason(self, monkeypatch):
def boom(queue_name=None):
raise DatabaseError("connection refused")
Expand Down