dj-queue 0.11.0__tar.gz → 0.12.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dj_queue-0.11.0 → dj_queue-0.12.0}/PKG-INFO +4 -2
- {dj_queue-0.11.0 → dj_queue-0.12.0}/README.md +3 -1
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/admin.py +13 -4
- dj_queue-0.12.0/dj_queue/apps.py +17 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/backend.py +29 -4
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/config.py +18 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/dashboard.py +299 -71
- dj_queue-0.12.0/dj_queue/migrations/0012_job_djq_jobs_b_queue_id_idx_job_djq_jobs_b_conc_idx_and_more.py +33 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/models/jobs.py +2 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/models/runtime.py +4 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/observability.py +36 -7
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/operations/_helpers.py +31 -32
- dj_queue-0.12.0/dj_queue/operations/_insert.py +32 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/operations/concurrency.py +293 -358
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/operations/jobs.py +406 -273
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/operations/queues.py +18 -21
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/operations/recurring.py +90 -22
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/queue_state.py +132 -7
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/base.py +24 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/connection_budget.py +2 -7
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/notify.py +10 -7
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/scheduler.py +34 -6
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/worker.py +13 -1
- dj_queue-0.12.0/dj_queue/sql/__init__.py +12 -0
- dj_queue-0.12.0/dj_queue/sql/common.py +174 -0
- dj_queue-0.12.0/dj_queue/sql/mysql.py +81 -0
- dj_queue-0.12.0/dj_queue/sql/postgres.py +392 -0
- dj_queue-0.12.0/dj_queue/sql/sqlite.py +8 -0
- dj_queue-0.12.0/dj_queue/sql/state.py +37 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/pyproject.toml +1 -1
- dj_queue-0.11.0/dj_queue/apps.py +0 -6
- dj_queue-0.11.0/dj_queue/operations/_insert.py +0 -43
- {dj_queue-0.11.0 → dj_queue-0.12.0}/LICENSE +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/__init__.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/api.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/contrib/__init__.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/contrib/asgi.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/contrib/gunicorn.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/contrib/prometheus.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/cron.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/dashboard_actions.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/db.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/exceptions.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/hooks.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/log.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/management/__init__.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/management/commands/__init__.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/management/commands/dj_queue.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/management/commands/dj_queue_health.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/management/commands/dj_queue_prune.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/metrics.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0001_initial.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0002_pause_semaphore.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0003_recurringtask_recurringexecution.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0004_dashboard.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0005_remove_recurringexecution_dj_queue_recurring_executions_task_key_run_at_unique_and_more.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0006_blockedexecution_dj_queue_bl_concurr_2d8393_idx_and_more.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0007_recurringtask_next_run_at.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0008_remove_blockedexecution_dj_queue_bl_concurr_1ce730_idx_and_more.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0009_remove_process_dj_queue_processes_name_supervisor_unique_and_more.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0010_remove_process_djq_pr_name_parent_uniq_and_more.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0011_remove_blockedexecution_djq_bl_b_conc_idx_and_more.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/__init__.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/models/__init__.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/models/recurring.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/operations/__init__.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/operations/cleanup.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/queue_selectors.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/routers.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/__init__.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/dispatcher.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/errors.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/interruptible.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/pidfile.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/pool.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/procline.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/supervisor.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/topology.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/task_results.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_dashboard_process_rows.html +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_dashboard_recurring_rows.html +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_dashboard_section_table.html +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_dashboard_semaphore_rows.html +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_paginator.html +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_queue_controls.html +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_sortable_header_cells.html +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/change_form.html +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/change_list.html +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/dashboard.html +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/includes/fieldset.html +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/queue_jobs.html +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templatetags/__init__.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templatetags/dj_queue_admin.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/urls.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/views.py +0 -0
- {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/wakeup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dj-queue
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Database-backed task queue backend for Django’s Tasks framework.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -812,7 +812,7 @@ Global options:
|
|
|
812
812
|
| Option | Default | Meaning |
|
|
813
813
|
|---|---|---|
|
|
814
814
|
| `mode` | `"fork"` | standalone runtime mode, either `"fork"` or `"async"` |
|
|
815
|
-
| `workers` | `[{"queues": "*", "threads": 3, "processes": 1, "polling_interval": 0.1}]` | worker topology and queue selectors |
|
|
815
|
+
| `workers` | `[{"queues": "*", "threads": 3, "processes": 1, "polling_interval": 0.1, "prefetch_multiplier": 2}]` | worker topology and queue selectors |
|
|
816
816
|
| `dispatchers` | `[{"batch_size": 500, "polling_interval": 1, "concurrency_maintenance": true, "concurrency_maintenance_interval": 600}]` | scheduled promotion and concurrency maintenance |
|
|
817
817
|
| `scheduler` | `{"dynamic_tasks_enabled": false, "polling_interval": 5}` | dynamic recurring polling; the scheduler only starts when recurring or cleanup work exists |
|
|
818
818
|
| `recurring` | `{}` | static recurring task definitions keyed by name |
|
|
@@ -839,6 +839,7 @@ Worker entry options:
|
|
|
839
839
|
| `threads` | `3` | worker threads per worker process |
|
|
840
840
|
| `processes` | `1` | worker processes in `fork` mode; normalized to `1` in `async` mode |
|
|
841
841
|
| `polling_interval` | `0.1` | seconds between worker polls |
|
|
842
|
+
| `prefetch_multiplier` | `2` | max claimed running/queued jobs per worker thread |
|
|
842
843
|
|
|
843
844
|
Dispatcher entry options:
|
|
844
845
|
|
|
@@ -914,6 +915,7 @@ queues = ["default", "email*"]
|
|
|
914
915
|
threads = 8
|
|
915
916
|
processes = 1
|
|
916
917
|
polling_interval = 0.1
|
|
918
|
+
prefetch_multiplier = 2
|
|
917
919
|
|
|
918
920
|
[[dispatchers]]
|
|
919
921
|
batch_size = 500
|
|
@@ -786,7 +786,7 @@ Global options:
|
|
|
786
786
|
| Option | Default | Meaning |
|
|
787
787
|
|---|---|---|
|
|
788
788
|
| `mode` | `"fork"` | standalone runtime mode, either `"fork"` or `"async"` |
|
|
789
|
-
| `workers` | `[{"queues": "*", "threads": 3, "processes": 1, "polling_interval": 0.1}]` | worker topology and queue selectors |
|
|
789
|
+
| `workers` | `[{"queues": "*", "threads": 3, "processes": 1, "polling_interval": 0.1, "prefetch_multiplier": 2}]` | worker topology and queue selectors |
|
|
790
790
|
| `dispatchers` | `[{"batch_size": 500, "polling_interval": 1, "concurrency_maintenance": true, "concurrency_maintenance_interval": 600}]` | scheduled promotion and concurrency maintenance |
|
|
791
791
|
| `scheduler` | `{"dynamic_tasks_enabled": false, "polling_interval": 5}` | dynamic recurring polling; the scheduler only starts when recurring or cleanup work exists |
|
|
792
792
|
| `recurring` | `{}` | static recurring task definitions keyed by name |
|
|
@@ -813,6 +813,7 @@ Worker entry options:
|
|
|
813
813
|
| `threads` | `3` | worker threads per worker process |
|
|
814
814
|
| `processes` | `1` | worker processes in `fork` mode; normalized to `1` in `async` mode |
|
|
815
815
|
| `polling_interval` | `0.1` | seconds between worker polls |
|
|
816
|
+
| `prefetch_multiplier` | `2` | max claimed running/queued jobs per worker thread |
|
|
816
817
|
|
|
817
818
|
Dispatcher entry options:
|
|
818
819
|
|
|
@@ -888,6 +889,7 @@ queues = ["default", "email*"]
|
|
|
888
889
|
threads = 8
|
|
889
890
|
processes = 1
|
|
890
891
|
polling_interval = 0.1
|
|
892
|
+
prefetch_multiplier = 2
|
|
891
893
|
|
|
892
894
|
[[dispatchers]]
|
|
893
895
|
batch_size = 500
|
|
@@ -82,9 +82,6 @@ def _install_dj_queue_admin_site(site):
|
|
|
82
82
|
site._dj_queue_dashboard_installed = True
|
|
83
83
|
|
|
84
84
|
|
|
85
|
-
_install_dj_queue_admin_site(admin.site)
|
|
86
|
-
|
|
87
|
-
|
|
88
85
|
def _format_admin_datetime(value):
|
|
89
86
|
if value is None:
|
|
90
87
|
return None
|
|
@@ -530,7 +527,19 @@ class JobAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
|
|
|
530
527
|
|
|
531
528
|
def get_queryset(self, request):
|
|
532
529
|
queryset = super().get_queryset(request)
|
|
533
|
-
|
|
530
|
+
if self._orders_by_status(request):
|
|
531
|
+
return queryset.annotate(status_rank=status_rank_expression())
|
|
532
|
+
return queryset
|
|
533
|
+
|
|
534
|
+
def _orders_by_status(self, request):
|
|
535
|
+
ordering = request.GET.get("o")
|
|
536
|
+
if not ordering:
|
|
537
|
+
return False
|
|
538
|
+
try:
|
|
539
|
+
status_index = self.get_list_display(request).index("display_status") + 1
|
|
540
|
+
except ValueError:
|
|
541
|
+
return False
|
|
542
|
+
return any(part.lstrip("-") == str(status_index) for part in ordering.split("."))
|
|
534
543
|
|
|
535
544
|
@admin.display(description="status", ordering="status_rank")
|
|
536
545
|
def display_status(self, obj):
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
from django.apps import apps
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DjQueueConfig(AppConfig):
|
|
6
|
+
name = "dj_queue"
|
|
7
|
+
verbose_name = "dj_queue"
|
|
8
|
+
|
|
9
|
+
def ready(self):
|
|
10
|
+
if not apps.is_installed("django.contrib.admin"):
|
|
11
|
+
return
|
|
12
|
+
|
|
13
|
+
from django.contrib import admin
|
|
14
|
+
|
|
15
|
+
from dj_queue.admin import _install_dj_queue_admin_site
|
|
16
|
+
|
|
17
|
+
_install_dj_queue_admin_site(admin.site)
|
|
@@ -3,6 +3,7 @@ from django.db import close_old_connections, connections
|
|
|
3
3
|
from django.tasks.backends.base import BaseTaskBackend
|
|
4
4
|
from django.tasks.exceptions import TaskResultDoesNotExist
|
|
5
5
|
|
|
6
|
+
from dj_queue.config import load_backend_config
|
|
6
7
|
from dj_queue.db import get_database_alias
|
|
7
8
|
from dj_queue.models import Job
|
|
8
9
|
from dj_queue.operations.jobs import (
|
|
@@ -47,8 +48,13 @@ class DjQueueBackend(BaseTaskBackend):
|
|
|
47
48
|
)
|
|
48
49
|
|
|
49
50
|
async def aenqueue(self, task, args, kwargs):
|
|
50
|
-
|
|
51
|
+
config = load_backend_config(self.alias)
|
|
52
|
+
return await sync_to_async(
|
|
53
|
+
_async_backend_call,
|
|
54
|
+
thread_sensitive=config.async_thread_sensitive,
|
|
55
|
+
)(
|
|
51
56
|
self.enqueue,
|
|
57
|
+
close_connections=config.async_close_connections,
|
|
52
58
|
task=task,
|
|
53
59
|
args=args,
|
|
54
60
|
kwargs=kwargs,
|
|
@@ -70,6 +76,17 @@ class DjQueueBackend(BaseTaskBackend):
|
|
|
70
76
|
for job, task, dispatch_outcome in created_jobs
|
|
71
77
|
]
|
|
72
78
|
|
|
79
|
+
async def aenqueue_all(self, task_calls):
|
|
80
|
+
config = load_backend_config(self.alias)
|
|
81
|
+
return await sync_to_async(
|
|
82
|
+
_async_backend_call,
|
|
83
|
+
thread_sensitive=config.async_thread_sensitive,
|
|
84
|
+
)(
|
|
85
|
+
self.enqueue_all,
|
|
86
|
+
close_connections=config.async_close_connections,
|
|
87
|
+
task_calls=task_calls,
|
|
88
|
+
)
|
|
89
|
+
|
|
73
90
|
def get_result(self, result_id):
|
|
74
91
|
alias = get_database_alias(self.alias)
|
|
75
92
|
try:
|
|
@@ -90,15 +107,23 @@ class DjQueueBackend(BaseTaskBackend):
|
|
|
90
107
|
return task_result_from_job(job)
|
|
91
108
|
|
|
92
109
|
async def aget_result(self, result_id):
|
|
93
|
-
|
|
110
|
+
config = load_backend_config(self.alias)
|
|
111
|
+
return await sync_to_async(
|
|
112
|
+
_async_backend_call,
|
|
113
|
+
thread_sensitive=config.async_thread_sensitive,
|
|
114
|
+
)(
|
|
94
115
|
self.get_result,
|
|
116
|
+
close_connections=config.async_close_connections,
|
|
95
117
|
result_id=result_id,
|
|
96
118
|
)
|
|
97
119
|
|
|
98
120
|
|
|
99
|
-
def _async_backend_call(method, /, **kwargs):
|
|
121
|
+
def _async_backend_call(method, /, *, close_connections=False, **kwargs):
|
|
100
122
|
close_old_connections()
|
|
101
123
|
try:
|
|
102
124
|
return method(**kwargs)
|
|
103
125
|
finally:
|
|
104
|
-
|
|
126
|
+
if close_connections:
|
|
127
|
+
connections.close_all()
|
|
128
|
+
else:
|
|
129
|
+
close_old_connections()
|
|
@@ -19,6 +19,7 @@ DEFAULT_WORKER = {
|
|
|
19
19
|
"threads": 3,
|
|
20
20
|
"processes": 1,
|
|
21
21
|
"polling_interval": 0.1,
|
|
22
|
+
"prefetch_multiplier": 2,
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
DEFAULT_DISPATCHER = {
|
|
@@ -52,6 +53,8 @@ DEFAULT_OPTIONS = {
|
|
|
52
53
|
"use_skip_locked": True,
|
|
53
54
|
"listen_notify": True,
|
|
54
55
|
"silence_polling": True,
|
|
56
|
+
"async_thread_sensitive": False,
|
|
57
|
+
"async_close_connections": False,
|
|
55
58
|
"on_thread_error": None,
|
|
56
59
|
}
|
|
57
60
|
|
|
@@ -75,6 +78,7 @@ class WorkerConfig(ConfigValue):
|
|
|
75
78
|
threads: int = 3
|
|
76
79
|
processes: int = 1
|
|
77
80
|
polling_interval: float = 0.1
|
|
81
|
+
prefetch_multiplier: int = 2
|
|
78
82
|
|
|
79
83
|
|
|
80
84
|
@dataclass(frozen=True, slots=True)
|
|
@@ -125,6 +129,8 @@ class BackendConfig(ConfigValue):
|
|
|
125
129
|
use_skip_locked: bool = True
|
|
126
130
|
listen_notify: bool = True
|
|
127
131
|
silence_polling: bool = True
|
|
132
|
+
async_thread_sensitive: bool = False
|
|
133
|
+
async_close_connections: bool = False
|
|
128
134
|
on_thread_error: str | None = None
|
|
129
135
|
skip_recurring: bool = False
|
|
130
136
|
only_work: bool = False
|
|
@@ -272,6 +278,12 @@ def _load_backend_config_uncached(
|
|
|
272
278
|
use_skip_locked=_bool_option(resolved_options["use_skip_locked"], "use_skip_locked"),
|
|
273
279
|
listen_notify=_bool_option(resolved_options["listen_notify"], "listen_notify"),
|
|
274
280
|
silence_polling=_bool_option(resolved_options["silence_polling"], "silence_polling"),
|
|
281
|
+
async_thread_sensitive=_bool_option(
|
|
282
|
+
resolved_options["async_thread_sensitive"], "async_thread_sensitive"
|
|
283
|
+
),
|
|
284
|
+
async_close_connections=_bool_option(
|
|
285
|
+
resolved_options["async_close_connections"], "async_close_connections"
|
|
286
|
+
),
|
|
275
287
|
on_thread_error=on_thread_error,
|
|
276
288
|
skip_recurring=skip_recurring,
|
|
277
289
|
only_work=only_work,
|
|
@@ -370,6 +382,8 @@ def _load_toml_options(config_path: Any, *, backend_alias: str) -> dict[str, Any
|
|
|
370
382
|
|
|
371
383
|
try:
|
|
372
384
|
config_payload = tomllib.loads(Path(config_path).read_text(encoding="utf-8"))
|
|
385
|
+
except OSError as exc:
|
|
386
|
+
raise ImproperlyConfigured(f"DJ_QUEUE_CONFIG could not be read: {exc}") from exc
|
|
373
387
|
except tomllib.TOMLDecodeError as exc:
|
|
374
388
|
raise ImproperlyConfigured(f"DJ_QUEUE_CONFIG TOML is invalid: {exc}") from exc
|
|
375
389
|
if not isinstance(config_payload, dict):
|
|
@@ -475,6 +489,10 @@ def _build_worker_configs(raw_workers: Any, mode: str) -> tuple[WorkerConfig, ..
|
|
|
475
489
|
raw_worker.get("polling_interval", DEFAULT_WORKER["polling_interval"]),
|
|
476
490
|
f"workers[{index}].polling_interval",
|
|
477
491
|
),
|
|
492
|
+
prefetch_multiplier=_positive_int(
|
|
493
|
+
raw_worker.get("prefetch_multiplier", DEFAULT_WORKER["prefetch_multiplier"]),
|
|
494
|
+
f"workers[{index}].prefetch_multiplier",
|
|
495
|
+
),
|
|
478
496
|
)
|
|
479
497
|
|
|
480
498
|
if mode == "async" and worker.processes > 1:
|
|
@@ -4,7 +4,7 @@ from urllib.parse import urlencode
|
|
|
4
4
|
from uuid import UUID
|
|
5
5
|
|
|
6
6
|
from django.core.paginator import Paginator
|
|
7
|
-
from django.db.models import F
|
|
7
|
+
from django.db.models import DateTimeField, F, Max, OuterRef, Subquery
|
|
8
8
|
from django.http import Http404
|
|
9
9
|
from django.urls import reverse
|
|
10
10
|
from django.utils import timezone
|
|
@@ -20,6 +20,7 @@ from dj_queue.queue_state import (
|
|
|
20
20
|
queue_state_count_key,
|
|
21
21
|
queue_state_queryset,
|
|
22
22
|
)
|
|
23
|
+
from dj_queue.models import RecurringExecution, RecurringTask, Semaphore
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
PAGE_SIZE = 100
|
|
@@ -256,84 +257,74 @@ def dashboard_context(*, backend_alias, query_params=None):
|
|
|
256
257
|
if query_params is None:
|
|
257
258
|
query_params = {}
|
|
258
259
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
260
|
+
now = timezone.now()
|
|
261
|
+
queue_database_alias = get_database_alias(backend_alias)
|
|
262
|
+
process_cutoff = observability.process_cutoff_for_backend(
|
|
263
|
+
backend_alias,
|
|
264
|
+
now=now,
|
|
265
|
+
max_age=config.process_alive_threshold,
|
|
266
|
+
)
|
|
267
|
+
queue_rows = observability.queue_rows(
|
|
268
|
+
backend_alias=backend_alias,
|
|
269
|
+
now=now,
|
|
270
|
+
process_cutoff=process_cutoff,
|
|
271
|
+
)
|
|
272
|
+
process_rows = observability.process_rows(
|
|
273
|
+
backend_alias=backend_alias,
|
|
274
|
+
now=now,
|
|
275
|
+
process_cutoff=process_cutoff,
|
|
276
|
+
scope="backend",
|
|
277
|
+
)
|
|
278
|
+
queue_section = _overview_section(
|
|
279
|
+
section="queues",
|
|
280
|
+
rows=queue_rows,
|
|
281
|
+
page_param="queues_page",
|
|
282
|
+
page_size=OVERVIEW_PAGE_SIZES["queues"],
|
|
283
|
+
sort_param="queues_sort",
|
|
284
|
+
query_params=query_params,
|
|
285
|
+
anchor="queue-summary",
|
|
286
|
+
)
|
|
287
|
+
process_section = _overview_section(
|
|
288
|
+
section="processes",
|
|
289
|
+
rows=process_rows,
|
|
290
|
+
page_param="processes_page",
|
|
291
|
+
page_size=OVERVIEW_PAGE_SIZES["processes"],
|
|
292
|
+
sort_param="processes_sort",
|
|
293
|
+
query_params=query_params,
|
|
294
|
+
anchor="process-summary",
|
|
295
|
+
)
|
|
296
|
+
recurring_section = _recurring_overview_section(
|
|
297
|
+
backend_alias=backend_alias,
|
|
298
|
+
now=now,
|
|
299
|
+
query_params=query_params,
|
|
300
|
+
)
|
|
301
|
+
semaphore_section = _semaphore_overview_section(
|
|
302
|
+
backend_alias=backend_alias,
|
|
303
|
+
query_params=query_params,
|
|
304
|
+
)
|
|
282
305
|
|
|
283
306
|
return {
|
|
284
307
|
"backend_alias": backend_alias,
|
|
285
308
|
"backend_choices": backend_choices(),
|
|
286
309
|
"config": config,
|
|
287
|
-
"queue_database_alias":
|
|
310
|
+
"queue_database_alias": queue_database_alias,
|
|
288
311
|
"summary_cards": _summary_cards(
|
|
289
312
|
backend_alias=backend_alias,
|
|
290
313
|
queue_rows=queue_rows,
|
|
291
314
|
process_rows=process_rows,
|
|
292
|
-
|
|
293
|
-
|
|
315
|
+
recurring_count=recurring_section["total_count"],
|
|
316
|
+
semaphore_count=semaphore_section["total_count"],
|
|
294
317
|
),
|
|
295
318
|
"backend_facts": _backend_facts(
|
|
296
319
|
config=config,
|
|
297
|
-
queue_database_alias=
|
|
298
|
-
recurring_count=
|
|
299
|
-
semaphore_count=
|
|
300
|
-
),
|
|
301
|
-
"queue_section": _overview_section(
|
|
302
|
-
section="queues",
|
|
303
|
-
rows=queue_rows,
|
|
304
|
-
page_param="queues_page",
|
|
305
|
-
page_size=OVERVIEW_PAGE_SIZES["queues"],
|
|
306
|
-
sort_param="queues_sort",
|
|
307
|
-
query_params=query_params,
|
|
308
|
-
anchor="queue-summary",
|
|
309
|
-
),
|
|
310
|
-
"process_section": _overview_section(
|
|
311
|
-
section="processes",
|
|
312
|
-
rows=process_rows,
|
|
313
|
-
page_param="processes_page",
|
|
314
|
-
page_size=OVERVIEW_PAGE_SIZES["processes"],
|
|
315
|
-
sort_param="processes_sort",
|
|
316
|
-
query_params=query_params,
|
|
317
|
-
anchor="process-summary",
|
|
318
|
-
),
|
|
319
|
-
"recurring_section": _overview_section(
|
|
320
|
-
section="recurring",
|
|
321
|
-
rows=recurring_rows,
|
|
322
|
-
page_param="recurring_page",
|
|
323
|
-
page_size=OVERVIEW_PAGE_SIZES["recurring"],
|
|
324
|
-
sort_param="recurring_sort",
|
|
325
|
-
query_params=query_params,
|
|
326
|
-
anchor="recurring-summary",
|
|
327
|
-
),
|
|
328
|
-
"semaphore_section": _overview_section(
|
|
329
|
-
section="semaphores",
|
|
330
|
-
rows=semaphore_rows,
|
|
331
|
-
page_param="semaphores_page",
|
|
332
|
-
page_size=OVERVIEW_PAGE_SIZES["semaphores"],
|
|
333
|
-
sort_param="semaphores_sort",
|
|
334
|
-
query_params=query_params,
|
|
335
|
-
anchor="semaphore-summary",
|
|
320
|
+
queue_database_alias=queue_database_alias,
|
|
321
|
+
recurring_count=recurring_section["total_count"],
|
|
322
|
+
semaphore_count=semaphore_section["total_count"],
|
|
336
323
|
),
|
|
324
|
+
"queue_section": queue_section,
|
|
325
|
+
"process_section": process_section,
|
|
326
|
+
"recurring_section": recurring_section,
|
|
327
|
+
"semaphore_section": semaphore_section,
|
|
337
328
|
}
|
|
338
329
|
|
|
339
330
|
|
|
@@ -449,7 +440,7 @@ def queue_page_context(*, backend_alias, queue_name, state, page_number, query_p
|
|
|
449
440
|
}
|
|
450
441
|
|
|
451
442
|
|
|
452
|
-
def _summary_cards(*, backend_alias, queue_rows, process_rows,
|
|
443
|
+
def _summary_cards(*, backend_alias, queue_rows, process_rows, recurring_count, semaphore_count):
|
|
453
444
|
paused_count = sum(1 for row in queue_rows if row["paused"])
|
|
454
445
|
ready_count = sum(row[queue_state_count_key("ready")] for row in queue_rows)
|
|
455
446
|
scheduled_count = sum(row[queue_state_count_key("scheduled")] for row in queue_rows)
|
|
@@ -497,8 +488,8 @@ def _summary_cards(*, backend_alias, queue_rows, process_rows, recurring_rows, s
|
|
|
497
488
|
},
|
|
498
489
|
{
|
|
499
490
|
"label": "control-plane",
|
|
500
|
-
"value":
|
|
501
|
-
"detail": f"{
|
|
491
|
+
"value": recurring_count + semaphore_count,
|
|
492
|
+
"detail": f"{recurring_count} recurring and {semaphore_count} semaphores",
|
|
502
493
|
},
|
|
503
494
|
)
|
|
504
495
|
|
|
@@ -547,7 +538,6 @@ def _overview_section(*, section, rows, page_param, page_size, sort_param, query
|
|
|
547
538
|
raw_sort = query_params.get(sort_param)
|
|
548
539
|
sort, explicit_sort = _resolve_overview_sort(section=section, raw_sort=raw_sort)
|
|
549
540
|
rows = _sort_overview_rows(rows=rows, section=section, sort=sort)
|
|
550
|
-
sort_value = sort if explicit_sort else None
|
|
551
541
|
|
|
552
542
|
if section == "processes":
|
|
553
543
|
page = _paginate_process_rows(
|
|
@@ -562,6 +552,72 @@ def _overview_section(*, section, rows, page_param, page_size, sort_param, query
|
|
|
562
552
|
page_number=query_params.get(page_param, 1),
|
|
563
553
|
)
|
|
564
554
|
|
|
555
|
+
return _section_payload_from_page(
|
|
556
|
+
section=section,
|
|
557
|
+
page=page,
|
|
558
|
+
query_params=query_params,
|
|
559
|
+
page_param=page_param,
|
|
560
|
+
sort_param=sort_param,
|
|
561
|
+
sort=sort,
|
|
562
|
+
explicit_sort=explicit_sort,
|
|
563
|
+
anchor=anchor,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _recurring_overview_section(*, backend_alias, now, query_params):
|
|
568
|
+
section = "recurring"
|
|
569
|
+
page_param = "recurring_page"
|
|
570
|
+
sort_param = "recurring_sort"
|
|
571
|
+
anchor = "recurring-summary"
|
|
572
|
+
raw_sort = query_params.get(sort_param)
|
|
573
|
+
sort, explicit_sort = _resolve_overview_sort(section=section, raw_sort=raw_sort)
|
|
574
|
+
page = _recurring_overview_page(
|
|
575
|
+
backend_alias=backend_alias,
|
|
576
|
+
now=now,
|
|
577
|
+
page_size=OVERVIEW_PAGE_SIZES[section],
|
|
578
|
+
page_number=query_params.get(page_param, 1),
|
|
579
|
+
sort=sort,
|
|
580
|
+
)
|
|
581
|
+
return _section_payload_from_page(
|
|
582
|
+
section=section,
|
|
583
|
+
page=page,
|
|
584
|
+
query_params=query_params,
|
|
585
|
+
page_param=page_param,
|
|
586
|
+
sort_param=sort_param,
|
|
587
|
+
sort=sort,
|
|
588
|
+
explicit_sort=explicit_sort,
|
|
589
|
+
anchor=anchor,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _semaphore_overview_section(*, backend_alias, query_params):
|
|
594
|
+
section = "semaphores"
|
|
595
|
+
page_param = "semaphores_page"
|
|
596
|
+
sort_param = "semaphores_sort"
|
|
597
|
+
anchor = "semaphore-summary"
|
|
598
|
+
raw_sort = query_params.get(sort_param)
|
|
599
|
+
sort, explicit_sort = _resolve_overview_sort(section=section, raw_sort=raw_sort)
|
|
600
|
+
page = _semaphore_overview_page(
|
|
601
|
+
backend_alias=backend_alias,
|
|
602
|
+
page_size=OVERVIEW_PAGE_SIZES[section],
|
|
603
|
+
page_number=query_params.get(page_param, 1),
|
|
604
|
+
sort=sort,
|
|
605
|
+
)
|
|
606
|
+
return _section_payload_from_page(
|
|
607
|
+
section=section,
|
|
608
|
+
page=page,
|
|
609
|
+
query_params=query_params,
|
|
610
|
+
page_param=page_param,
|
|
611
|
+
sort_param=sort_param,
|
|
612
|
+
sort=sort,
|
|
613
|
+
explicit_sort=explicit_sort,
|
|
614
|
+
anchor=anchor,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _section_payload_from_page(
|
|
619
|
+
*, section, page, query_params, page_param, sort_param, sort, explicit_sort, anchor
|
|
620
|
+
):
|
|
565
621
|
return _section_payload(
|
|
566
622
|
section=section,
|
|
567
623
|
rows=page["rows"],
|
|
@@ -574,7 +630,7 @@ def _overview_section(*, section, rows, page_param, page_size, sort_param, query
|
|
|
574
630
|
page_param=page_param,
|
|
575
631
|
sort_param=sort_param,
|
|
576
632
|
sort=sort,
|
|
577
|
-
sort_value=
|
|
633
|
+
sort_value=sort if explicit_sort else None,
|
|
578
634
|
explicit_sort=explicit_sort,
|
|
579
635
|
anchor=anchor,
|
|
580
636
|
)
|
|
@@ -645,6 +701,178 @@ def _paginate_standard_rows(*, rows, page_size, page_number):
|
|
|
645
701
|
}
|
|
646
702
|
|
|
647
703
|
|
|
704
|
+
def _recurring_overview_page(*, backend_alias, now, page_size, page_number, sort):
|
|
705
|
+
if _recurring_sort_requires_python(sort):
|
|
706
|
+
rows = [
|
|
707
|
+
_recurring_row_with_jobs_url(row, backend_alias=backend_alias)
|
|
708
|
+
for row in observability.recurring_rows_for_backend(backend_alias=backend_alias, now=now)
|
|
709
|
+
]
|
|
710
|
+
rows = _sort_overview_rows(rows=rows, section="recurring", sort=sort)
|
|
711
|
+
return _paginate_standard_rows(rows=rows, page_size=page_size, page_number=page_number)
|
|
712
|
+
|
|
713
|
+
alias = get_database_alias(backend_alias)
|
|
714
|
+
last_run_at = (
|
|
715
|
+
RecurringExecution.objects.using(alias)
|
|
716
|
+
.filter(backend_alias=backend_alias, task_key=OuterRef("key"))
|
|
717
|
+
.values("task_key")
|
|
718
|
+
.annotate(value=Max("run_at"))
|
|
719
|
+
.values("value")[:1]
|
|
720
|
+
)
|
|
721
|
+
queryset = (
|
|
722
|
+
RecurringTask.objects.using(alias)
|
|
723
|
+
.filter(backend_alias=backend_alias)
|
|
724
|
+
.annotate(last_run_at=Subquery(last_run_at, output_field=DateTimeField()))
|
|
725
|
+
.order_by(*_overview_queryset_ordering(section="recurring", sort=sort, tie_breaker="key"))
|
|
726
|
+
)
|
|
727
|
+
paginator = Paginator(queryset, page_size)
|
|
728
|
+
page_obj = paginator.get_page(page_number)
|
|
729
|
+
total_count = paginator.count
|
|
730
|
+
return {
|
|
731
|
+
"rows": _recurring_task_rows(
|
|
732
|
+
page_obj.object_list,
|
|
733
|
+
backend_alias=backend_alias,
|
|
734
|
+
now=now,
|
|
735
|
+
),
|
|
736
|
+
"number": page_obj.number,
|
|
737
|
+
"total_pages": paginator.num_pages,
|
|
738
|
+
"total_count": total_count,
|
|
739
|
+
"start_index": page_obj.start_index() if total_count else 0,
|
|
740
|
+
"end_index": page_obj.end_index() if total_count else 0,
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _semaphore_overview_page(*, backend_alias, page_size, page_number, sort):
|
|
745
|
+
alias = get_database_alias(backend_alias)
|
|
746
|
+
if _semaphore_sort_requires_python(sort):
|
|
747
|
+
rows = [
|
|
748
|
+
_semaphore_row_with_jobs_url(row, backend_alias=backend_alias)
|
|
749
|
+
for row in observability.semaphore_rows_for_backend(backend_alias=backend_alias)
|
|
750
|
+
]
|
|
751
|
+
rows = _sort_overview_rows(
|
|
752
|
+
rows=rows,
|
|
753
|
+
section="semaphores",
|
|
754
|
+
sort=_sort_with_tie_breaker(sort, "key"),
|
|
755
|
+
)
|
|
756
|
+
return _paginate_standard_rows(rows=rows, page_size=page_size, page_number=page_number)
|
|
757
|
+
|
|
758
|
+
queryset = (
|
|
759
|
+
Semaphore.objects.using(alias)
|
|
760
|
+
.annotate(blocked_waiters=observability.semaphore_blocked_waiter_count_expression(alias))
|
|
761
|
+
.order_by(
|
|
762
|
+
*_overview_queryset_ordering(
|
|
763
|
+
section="semaphores",
|
|
764
|
+
sort=sort,
|
|
765
|
+
field_map={"available_slots": "value"},
|
|
766
|
+
tie_breaker="key",
|
|
767
|
+
)
|
|
768
|
+
)
|
|
769
|
+
)
|
|
770
|
+
paginator = Paginator(queryset, page_size)
|
|
771
|
+
page_obj = paginator.get_page(page_number)
|
|
772
|
+
total_count = paginator.count
|
|
773
|
+
return {
|
|
774
|
+
"rows": _semaphore_rows(page_obj.object_list, backend_alias=backend_alias, alias=alias),
|
|
775
|
+
"number": page_obj.number,
|
|
776
|
+
"total_pages": paginator.num_pages,
|
|
777
|
+
"total_count": total_count,
|
|
778
|
+
"start_index": page_obj.start_index() if total_count else 0,
|
|
779
|
+
"end_index": page_obj.end_index() if total_count else 0,
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _recurring_sort_requires_python(sort):
|
|
784
|
+
return any(part.removeprefix("-") == "next_run" for part in _parse_sort_fields(sort))
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _semaphore_sort_requires_python(sort):
|
|
788
|
+
return any(part.removeprefix("-") == "blocked_waiters" for part in _parse_sort_fields(sort))
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _sort_with_tie_breaker(sort, tie_breaker):
|
|
792
|
+
field_names = {part.removeprefix("-") for part in _parse_sort_fields(sort)}
|
|
793
|
+
if tie_breaker in field_names:
|
|
794
|
+
return sort
|
|
795
|
+
return f"{sort}.{tie_breaker}"
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _recurring_task_rows(tasks, *, backend_alias, now):
|
|
799
|
+
return [
|
|
800
|
+
_recurring_row_with_jobs_url(
|
|
801
|
+
{
|
|
802
|
+
"key": task.key,
|
|
803
|
+
"task_path": task.task_path,
|
|
804
|
+
"queue_name": task.queue_name,
|
|
805
|
+
"schedule": task.schedule,
|
|
806
|
+
"static": task.static,
|
|
807
|
+
"last_run_at": task.last_run_at,
|
|
808
|
+
"next_run_at": task.next_run_at or _next_run_at(task.schedule, now),
|
|
809
|
+
},
|
|
810
|
+
backend_alias=backend_alias,
|
|
811
|
+
)
|
|
812
|
+
for task in tasks
|
|
813
|
+
]
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _recurring_row_with_jobs_url(row, *, backend_alias):
|
|
817
|
+
return {
|
|
818
|
+
**row,
|
|
819
|
+
"jobs_url": _job_changelist_url(
|
|
820
|
+
backend_alias=backend_alias,
|
|
821
|
+
recurring_task_key=row["key"],
|
|
822
|
+
),
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _semaphore_rows(semaphores, *, backend_alias, alias):
|
|
827
|
+
return [
|
|
828
|
+
_semaphore_row_with_jobs_url(
|
|
829
|
+
{
|
|
830
|
+
"scope": "queue_database",
|
|
831
|
+
"queue_database_alias": alias,
|
|
832
|
+
"key": semaphore.key,
|
|
833
|
+
"available_slots": semaphore.value,
|
|
834
|
+
"limit": semaphore.limit,
|
|
835
|
+
"blocked_waiters": semaphore.blocked_waiters,
|
|
836
|
+
"expires_at": semaphore.expires_at,
|
|
837
|
+
},
|
|
838
|
+
backend_alias=backend_alias,
|
|
839
|
+
)
|
|
840
|
+
for semaphore in semaphores
|
|
841
|
+
]
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _semaphore_row_with_jobs_url(row, *, backend_alias):
|
|
845
|
+
return {
|
|
846
|
+
**row,
|
|
847
|
+
"jobs_url": _job_changelist_url(
|
|
848
|
+
backend_alias=backend_alias,
|
|
849
|
+
concurrency_key=row["key"],
|
|
850
|
+
),
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _overview_queryset_ordering(*, section, sort, field_map=None, tie_breaker=None):
|
|
855
|
+
if field_map is None:
|
|
856
|
+
field_map = {}
|
|
857
|
+
|
|
858
|
+
order_by = []
|
|
859
|
+
ordered_fields = set()
|
|
860
|
+
for part in _parse_sort_fields(sort):
|
|
861
|
+
field_name = part.removeprefix("-")
|
|
862
|
+
key_name = OVERVIEW_SORTS[section]["fields"][field_name]["key"]
|
|
863
|
+
query_field = field_map.get(key_name, key_name)
|
|
864
|
+
descending = part.startswith("-")
|
|
865
|
+
expression = F(query_field)
|
|
866
|
+
order_by.append(
|
|
867
|
+
expression.desc(nulls_last=True) if descending else expression.asc(nulls_last=True)
|
|
868
|
+
)
|
|
869
|
+
ordered_fields.add(query_field)
|
|
870
|
+
|
|
871
|
+
if tie_breaker and tie_breaker not in ordered_fields:
|
|
872
|
+
order_by.append(F(tie_breaker).asc())
|
|
873
|
+
return order_by
|
|
874
|
+
|
|
875
|
+
|
|
648
876
|
def _overview_query(*, query_params, page_param, page_number, sort_param=None, sort=None):
|
|
649
877
|
params = query_params.copy()
|
|
650
878
|
if str(page_number) == "1":
|