From 0779bbf428b616863b0372c3dd50daf511a9f9c6 Mon Sep 17 00:00:00 2001 From: Burak Eyler Date: Sun, 13 Sep 2026 14:35:46 +0300 Subject: [PATCH 1/3] Add ox_prune --queue, to match ox_health --queue A deployment keeping two queues with different retention needs had no way to prune them separately. `--queue` narrows which task rows are eligible, with the same name and meaning as in `ox_health`; batching is unchanged. Schedule tick rows belong to no queue and are pruned as before. Closes #37 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 6 +++ docs/configuration.md | 1 + src/django_ox/management/commands/ox_prune.py | 11 +++++ tests/test_prune.py | 41 ++++++++++++++++++- 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecb1393..ac6165e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `ox_prune --queue` restricts pruning to one queue's task rows, matching + `ox_health --queue`, so queues with different retention needs can each + be pruned with their own `--older-than`. + ### Changed - `ox_health --max-age` and `--worker-timeout` accept the duration forms diff --git a/docs/configuration.md b/docs/configuration.md index c6b8b05..e529c38 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -161,6 +161,7 @@ python manage.py ox_prune --older-than 7d | Flag | Default | Meaning | | --- | --- | --- | +| `--queue` | all queues | Delete only this queue's task rows, so queues with different retention needs can be pruned separately. Same name and meaning as `ox_health --queue`. Schedule tick rows belong to no queue and are pruned as usual. | | `--older-than` | `7d` | Minimum time since the task finished. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. | | `--include-failed` | off | Also delete FAILED and LOST rows. By default they are kept, because they hold the per-attempt tracebacks and can be retried. | | `--batch-size` | `1000` | Rows per DELETE statement, so pruning a large table never takes a long lock or builds a giant IN clause. Must be at least 1. | diff --git a/src/django_ox/management/commands/ox_prune.py b/src/django_ox/management/commands/ox_prune.py index 95acf43..1c968ba 100644 --- a/src/django_ox/management/commands/ox_prune.py +++ b/src/django_ox/management/commands/ox_prune.py @@ -12,6 +12,11 @@ class Command(BaseCommand): help = "Delete finished task rows older than a cutoff." def add_arguments(self, parser: CommandParser) -> None: + parser.add_argument( + "--queue", + default=None, + help="Restrict pruning to one queue's task rows (default: all queues).", + ) parser.add_argument( "--older-than", default="7d", @@ -59,6 +64,12 @@ def handle(self, *args: Any, **options: Any) -> None: statuses += [OxTask.Status.FAILED, OxTask.Status.LOST] prunable = OxTask.objects.filter(status__in=statuses, finished_at__lt=cutoff) label = "/".join(statuses) + queue: str | None = options["queue"] + if queue is not None: + # Narrows which rows are eligible; batching is unchanged. Schedule + # ticks belong to no queue, so they are pruned as without --queue. + prunable = prunable.filter(queue_name=queue) + label = f"{label} (queue {queue})" # Old schedule ticks are dispatch-log bookkeeping, but each # schedule's latest tick is the anchor the dispatcher measures diff --git a/tests/test_prune.py b/tests/test_prune.py index bd25fae..f797c2c 100644 --- a/tests/test_prune.py +++ b/tests/test_prune.py @@ -10,11 +10,12 @@ from django_ox.models import OxScheduleTick, OxTask -def make_task(status, *, finished_days_ago=None): +def make_task(status, *, finished_days_ago=None, queue_name="default"): now = timezone.now() return OxTask.objects.create( task_path="tests.tasks.add", backend_name="default", + queue_name=queue_name, enqueued_at=now - timedelta(days=400), status=status, finished_at=( @@ -73,6 +74,44 @@ def test_prunes_old_successful_only_by_default(self): assert OxTask.objects.filter(pk=young_ok.pk).exists() assert "Deleted 1 SUCCESSFUL/DISCARDED task row(s)" in out + def test_queue_restricts_pruning_to_that_queue(self): + emails = make_task( + OxTask.Status.SUCCESSFUL, finished_days_ago=8, queue_name="emails" + ) + reports = make_task( + OxTask.Status.SUCCESSFUL, finished_days_ago=8, queue_name="reports" + ) + + out = prune("--queue", "emails") + + assert not OxTask.objects.filter(pk=emails.pk).exists() + assert OxTask.objects.filter(pk=reports.pk).exists() + assert "Deleted 1 SUCCESSFUL/DISCARDED (queue emails) task row(s)" in out + + def test_queue_dry_run_counts_only_that_queue(self): + make_task(OxTask.Status.SUCCESSFUL, finished_days_ago=8, queue_name="emails") + make_task(OxTask.Status.SUCCESSFUL, finished_days_ago=8, queue_name="reports") + make_task(OxTask.Status.SUCCESSFUL, finished_days_ago=8, queue_name="reports") + + out = prune("--queue", "reports", "--dry-run") + + assert "Would delete 2 SUCCESSFUL/DISCARDED (queue reports) task row(s)" in out + assert OxTask.objects.count() == 3 + + def test_queue_keeps_batching(self): + for _ in range(5): + make_task( + OxTask.Status.SUCCESSFUL, finished_days_ago=8, queue_name="emails" + ) + kept = make_task( + OxTask.Status.SUCCESSFUL, finished_days_ago=8, queue_name="reports" + ) + + out = prune("--queue", "emails", "--batch-size", "2") + + assert "Deleted 5 SUCCESSFUL/DISCARDED (queue emails) task row(s)" in out + assert list(OxTask.objects.values_list("pk", flat=True)) == [kept.pk] + def test_include_failed_prunes_failed_and_lost_too(self): make_task(OxTask.Status.SUCCESSFUL, finished_days_ago=8) make_task(OxTask.Status.FAILED, finished_days_ago=8) From 990b2c613176ff67fbbc6d34a3e88c23009f206a Mon Sep 17 00:00:00 2001 From: Burak Eyler Date: Mon, 14 Sep 2026 21:12:29 +0300 Subject: [PATCH 2/3] docs: regenerate llms-full.txt for the ox_prune --queue docs Co-Authored-By: Claude Sonnet 5 --- docs/llms-full.txt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 07dfd51..3075e34 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -370,6 +370,7 @@ python manage.py ox_prune --older-than 7d | Flag | Default | Meaning | | --- | --- | --- | +| `--queue` | all queues | Delete only this queue's task rows, so queues with different retention needs can be pruned separately. Same name and meaning as `ox_health --queue`. Schedule tick rows belong to no queue and are pruned as usual. | | `--older-than` | `7d` | Minimum time since the task finished. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. | | `--include-failed` | off | Also delete FAILED and LOST rows. By default they are kept, because they hold the per-attempt tracebacks and can be retried. | | `--batch-size` | `1000` | Rows per DELETE statement, so pruning a large table never takes a long lock or builds a giant IN clause. Must be at least 1. | @@ -3528,6 +3529,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `ox_prune --queue` restricts pruning to one queue's task rows, matching + `ox_health --queue`, so queues with different retention needs can each + be pruned with their own `--older-than`. + ### Changed - `ox_health --max-age` and `--worker-timeout` accept the duration forms From 438b5a914413fd6e7f289a3f1c111dec7421b557 Mon Sep 17 00:00:00 2001 From: PhiLily <252857470+PhiLily@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:46:44 +0300 Subject: [PATCH 3/3] Say that ox_prune --queue still prunes every schedule's ticks, and test it The flag narrows task rows only. The tick log is pruned for every schedule whatever --queue names, because deleting a task clears its ticks' link to it and an anchor never had one. The docs said ticks "belong to no queue and are pruned as usual", which hid that a run for one queue, or for a queue name with no rows, still deletes old ticks at its own cutoff. The configuration page and the changelog now say so, and a test pins it. The README's ox_prune table gains the --queue row it was missing, and test_queue_keeps_batching counts queries, so a single-statement delete no longer passes it. --- CHANGELOG.md | 3 ++- README.md | 4 +++- docs/configuration.md | 7 +++++- docs/llms-full.txt | 10 ++++++-- src/django_ox/management/commands/ox_prune.py | 6 +++-- tests/test_prune.py | 24 +++++++++++++++++-- 6 files changed, 45 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6165e..aa782e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ox_prune --queue` restricts pruning to one queue's task rows, matching `ox_health --queue`, so queues with different retention needs can each - be pruned with their own `--older-than`. + be pruned with their own `--older-than`. Old schedule ticks are still + pruned for every schedule. ### Changed diff --git a/README.md b/README.md index df339c6..9a7da61 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ python manage.py ox_prune --older-than 7d | Flag | Default | Meaning | | --- | --- | --- | +| `--queue` | all queues | Delete only this queue's task rows, so queues with different retention needs can be pruned separately. | | `--older-than` | `7d` | Minimum time since the task finished. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. | | `--include-failed` | off | Also delete FAILED and LOST rows. By default they are kept: they hold the per-attempt tracebacks and can be retried. | | `--batch-size` | `1000` | Rows per DELETE statement, so pruning a large table never takes a long lock or builds a giant IN clause. | @@ -209,7 +210,8 @@ python manage.py ox_prune --older-than 7d Only SUCCESSFUL and DISCARDED rows (and, with `--include-failed`, FAILED and LOST rows) past the cutoff are deleted. READY and RUNNING rows are never touched, whatever their age. Old rows from the recurring-schedule tick log are cleared with the same -cutoff, always keeping each schedule's most recent tick. +cutoff, always keeping each schedule's most recent tick, and `--queue` does +not narrow that. ## Health and monitoring diff --git a/docs/configuration.md b/docs/configuration.md index e529c38..9dcae2e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -161,7 +161,7 @@ python manage.py ox_prune --older-than 7d | Flag | Default | Meaning | | --- | --- | --- | -| `--queue` | all queues | Delete only this queue's task rows, so queues with different retention needs can be pruned separately. Same name and meaning as `ox_health --queue`. Schedule tick rows belong to no queue and are pruned as usual. | +| `--queue` | all queues | Delete only this queue's task rows, so queues with different retention needs can be pruned separately. Same name and meaning as `ox_health --queue`. Old schedule ticks are still pruned for every schedule. | | `--older-than` | `7d` | Minimum time since the task finished. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. | | `--include-failed` | off | Also delete FAILED and LOST rows. By default they are kept, because they hold the per-attempt tracebacks and can be retried. | | `--batch-size` | `1000` | Rows per DELETE statement, so pruning a large table never takes a long lock or builds a giant IN clause. Must be at least 1. | @@ -177,6 +177,11 @@ been removed from settings is kept by the same rule; such rows are harmless and can be deleted by hand if unwanted. See [Recurring tasks](recurring-tasks.md#missed-ticks). +`--queue` narrows the task rows, not the tick log. A run for one queue +prunes every schedule's old ticks at its own cutoff, so when queues are +pruned separately, the shortest `--older-than` decides how much tick +history stays. + ## ox_health A health check for cron alerting and container probes: exits 0 when diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 3075e34..f4c842f 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -370,7 +370,7 @@ python manage.py ox_prune --older-than 7d | Flag | Default | Meaning | | --- | --- | --- | -| `--queue` | all queues | Delete only this queue's task rows, so queues with different retention needs can be pruned separately. Same name and meaning as `ox_health --queue`. Schedule tick rows belong to no queue and are pruned as usual. | +| `--queue` | all queues | Delete only this queue's task rows, so queues with different retention needs can be pruned separately. Same name and meaning as `ox_health --queue`. Old schedule ticks are still pruned for every schedule. | | `--older-than` | `7d` | Minimum time since the task finished. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. | | `--include-failed` | off | Also delete FAILED and LOST rows. By default they are kept, because they hold the per-attempt tracebacks and can be retried. | | `--batch-size` | `1000` | Rows per DELETE statement, so pruning a large table never takes a long lock or builds a giant IN clause. Must be at least 1. | @@ -386,6 +386,11 @@ been removed from settings is kept by the same rule; such rows are harmless and can be deleted by hand if unwanted. See [Recurring tasks](recurring-tasks.md#missed-ticks). +`--queue` narrows the task rows, not the tick log. A run for one queue +prunes every schedule's old ticks at its own cutoff, so when queues are +pruned separately, the shortest `--older-than` decides how much tick +history stays. + ## ox_health A health check for cron alerting and container probes: exits 0 when @@ -3533,7 +3538,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ox_prune --queue` restricts pruning to one queue's task rows, matching `ox_health --queue`, so queues with different retention needs can each - be pruned with their own `--older-than`. + be pruned with their own `--older-than`. Old schedule ticks are still + pruned for every schedule. ### Changed diff --git a/src/django_ox/management/commands/ox_prune.py b/src/django_ox/management/commands/ox_prune.py index 1c968ba..d5d91ca 100644 --- a/src/django_ox/management/commands/ox_prune.py +++ b/src/django_ox/management/commands/ox_prune.py @@ -66,8 +66,10 @@ def handle(self, *args: Any, **options: Any) -> None: label = "/".join(statuses) queue: str | None = options["queue"] if queue is not None: - # Narrows which rows are eligible; batching is unchanged. Schedule - # ticks belong to no queue, so they are pruned as without --queue. + # Task rows only. The tick log below is pruned for every schedule + # whatever --queue names: deleting a task clears its ticks' link + # to it, and an anchor never had one, so a tick's queue cannot be + # read reliably. prunable = prunable.filter(queue_name=queue) label = f"{label} (queue {queue})" diff --git a/tests/test_prune.py b/tests/test_prune.py index f797c2c..b140a61 100644 --- a/tests/test_prune.py +++ b/tests/test_prune.py @@ -98,7 +98,7 @@ def test_queue_dry_run_counts_only_that_queue(self): assert "Would delete 2 SUCCESSFUL/DISCARDED (queue reports) task row(s)" in out assert OxTask.objects.count() == 3 - def test_queue_keeps_batching(self): + def test_queue_keeps_batching(self, django_assert_num_queries): for _ in range(5): make_task( OxTask.Status.SUCCESSFUL, finished_days_ago=8, queue_name="emails" @@ -107,11 +107,31 @@ def test_queue_keeps_batching(self): OxTask.Status.SUCCESSFUL, finished_days_ago=8, queue_name="reports" ) - out = prune("--queue", "emails", "--batch-size", "2") + # The same fifteen queries as test_deletes_in_batches: the queue is + # one more condition on each statement, not one more statement. + with django_assert_num_queries(15): + out = prune("--queue", "emails", "--batch-size", "2") assert "Deleted 5 SUCCESSFUL/DISCARDED (queue emails) task row(s)" in out assert list(OxTask.objects.values_list("pk", flat=True)) == [kept.pk] + def test_queue_still_prunes_every_schedules_old_ticks(self): + make_tick("a", scheduled_days_ago=30) + latest_a = make_tick("a", scheduled_days_ago=10) + make_tick("b", scheduled_days_ago=20) + latest_b = make_tick("b", scheduled_days_ago=9) + + # No task row is in this queue. The tick log is pruned all the same: + # a tick's queue cannot be read reliably, so --queue does not narrow it. + out = prune("--queue", "emails") + + assert set(OxScheduleTick.objects.values_list("pk", flat=True)) == { + latest_a.pk, + latest_b.pk, + } + assert "Deleted 0 SUCCESSFUL/DISCARDED (queue emails) task row(s)" in out + assert "Deleted 2 schedule tick row(s)" in out + def test_include_failed_prunes_failed_and_lost_too(self): make_task(OxTask.Status.SUCCESSFUL, finished_days_ago=8) make_task(OxTask.Status.FAILED, finished_days_ago=8)