steady-queue 0.2.0__tar.gz → 0.2.1__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.
- {steady_queue-0.2.0/steady_queue.egg-info → steady_queue-0.2.1}/PKG-INFO +1 -1
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/__init__.py +1 -1
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/recurring_execution.py +8 -4
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/recurring_task.py +5 -1
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/supervisor.py +3 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1/steady_queue.egg-info}/PKG-INFO +1 -1
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue.egg-info/SOURCES.txt +1 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/tests/test_executions.py +57 -1
- steady_queue-0.2.1/tests/test_fork_safety.py +183 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/tests/test_process_lifecycle.py +24 -10
- steady_queue-0.2.1/tests/test_recurring_execution.py +36 -0
- steady_queue-0.2.0/tests/test_fork_safety.py +0 -98
- {steady_queue-0.2.0 → steady_queue-0.2.1}/LICENSE +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/README.md +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/pyproject.toml +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/setup.cfg +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/admin.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/app_executor.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/apps.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/arguments.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/backend.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/collections.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/concurrency.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/configuration.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/db_router.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/management/__init__.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/management/commands/__init__.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/management/commands/steady_queue.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/migrations/0001_initial.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/migrations/__init__.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/__init__.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/base.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/blocked_execution.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/claimed_execution.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/clearable.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/concurrency_controls.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/dispatching.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/executable.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/execution.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/executor.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/failed_execution.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/job.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/pause.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/process.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/prunable.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/queue.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/ready_execution.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/retryable.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/schedulable.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/scheduled_execution.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/models/semaphore.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/__init__.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/base.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/concurrent.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/dispatcher.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/errors.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/interruptible.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/maintenance.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/pidfile.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/pidfiled.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/poller.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/pool.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/registrable.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/runnable.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/scheduler.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/signals.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/supervised.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/timer.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/processes/worker.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/queue_selector.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/recurring_schedule.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/recurring_task.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue/task.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue.egg-info/dependency_links.txt +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue.egg-info/requires.txt +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/steady_queue.egg-info/top_level.txt +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/tests/test_arguments.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/tests/test_backend.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/tests/test_concurrency_controls.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/tests/test_configuration.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/tests/test_db_router.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/tests/test_job.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/tests/test_pidfile.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/tests/test_queue_selector.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/tests/test_recurring_task.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/tests/test_semaphore.py +0 -0
- {steady_queue-0.2.0 → steady_queue-0.2.1}/tests/test_task.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: steady-queue
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: An asynchronous task library for Django that supports delayed tasks, concurrency controls, recurring tasks, pausing queues, numeric priorities per task, priorities by queue order and bulk enqueueing.
|
|
5
5
|
Author-email: Elias Hernandis <elias@hernandis.me>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from django.db import models
|
|
1
|
+
from django.db import IntegrityError, models
|
|
2
2
|
from django.tasks import TaskResult
|
|
3
3
|
|
|
4
4
|
from .execution import Execution, ExecutionQuerySet
|
|
@@ -9,9 +9,10 @@ class RecurringExecutionQuerySet(ExecutionQuerySet):
|
|
|
9
9
|
return self.filter(job__isnull=True)
|
|
10
10
|
|
|
11
11
|
def record(self, task_result: TaskResult, task, run_at):
|
|
12
|
-
|
|
13
|
-
task=task, run_at=run_at,
|
|
14
|
-
|
|
12
|
+
try:
|
|
13
|
+
self.create(task=task, run_at=run_at, job_id=task_result.id)
|
|
14
|
+
except IntegrityError as e:
|
|
15
|
+
raise self.model.AlreadyRecorded from e
|
|
15
16
|
|
|
16
17
|
def clear_in_batches(self, batch_size=500):
|
|
17
18
|
while True:
|
|
@@ -21,6 +22,9 @@ class RecurringExecutionQuerySet(ExecutionQuerySet):
|
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
class RecurringExecution(Execution):
|
|
25
|
+
class AlreadyRecorded(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
24
28
|
class Meta:
|
|
25
29
|
verbose_name = "recurring execution"
|
|
26
30
|
verbose_name_plural = "recurring executions"
|
|
@@ -97,7 +97,10 @@ class RecurringTask(UpdatedAtMixin, BaseModel):
|
|
|
97
97
|
return self.recurring_executions.max("run_at")
|
|
98
98
|
|
|
99
99
|
def enqueue(self, run_at: datetime):
|
|
100
|
-
|
|
100
|
+
try:
|
|
101
|
+
return self.enqueue_and_record(run_at)
|
|
102
|
+
except RecurringExecution.AlreadyRecorded:
|
|
103
|
+
return False
|
|
101
104
|
|
|
102
105
|
def enqueue_and_record(self, run_at: datetime):
|
|
103
106
|
with transaction.atomic(using=self._state.db):
|
|
@@ -108,6 +111,7 @@ class RecurringTask(UpdatedAtMixin, BaseModel):
|
|
|
108
111
|
queue_name=self.queue_name, priority=self.priority
|
|
109
112
|
).enqueue(*args, **kwargs)
|
|
110
113
|
RecurringExecution.objects.record(task_result, self, run_at)
|
|
114
|
+
return task_result
|
|
111
115
|
|
|
112
116
|
@property
|
|
113
117
|
def previous_time(self) -> datetime:
|
|
@@ -86,6 +86,9 @@ class Supervisor(Maintenance, Signals, Pidfiled, Registrable, Interruptible, Bas
|
|
|
86
86
|
instance.supervisor = self.process
|
|
87
87
|
instance.mode = "fork"
|
|
88
88
|
|
|
89
|
+
# Replacement recovery queries the database after the startup reset.
|
|
90
|
+
# Clear that connection and pool state before the child inherits it.
|
|
91
|
+
self.reset_database_connections()
|
|
89
92
|
if (pid := os.fork()) == 0:
|
|
90
93
|
# child
|
|
91
94
|
instance.start()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: steady-queue
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: An asynchronous task library for Django that supports delayed tasks, concurrency controls, recurring tasks, pausing queues, numeric priorities per task, priorities by queue order and bulk enqueueing.
|
|
5
5
|
Author-email: Elias Hernandis <elias@hernandis.me>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
1
2
|
from datetime import timedelta
|
|
3
|
+
from unittest import skipUnless
|
|
4
|
+
from unittest.mock import patch
|
|
2
5
|
|
|
3
|
-
from django.
|
|
6
|
+
from django.db import connections
|
|
7
|
+
from django.test import TestCase, TransactionTestCase
|
|
4
8
|
from django.utils import timezone
|
|
5
9
|
|
|
6
10
|
import steady_queue
|
|
@@ -14,6 +18,7 @@ from steady_queue.models import (
|
|
|
14
18
|
ScheduledExecution,
|
|
15
19
|
Semaphore,
|
|
16
20
|
)
|
|
21
|
+
from steady_queue.models.claimed_execution import ClaimedExecutionQuerySet
|
|
17
22
|
from tests.dummy.tasks import dummy_task, limited_task, limited_task_with_lambda_key
|
|
18
23
|
|
|
19
24
|
|
|
@@ -106,6 +111,57 @@ class ReadyExecutionTestCase(TestHelperMixin, TestCase):
|
|
|
106
111
|
self.assertEqual(ReadyExecution.objects.count(), 1)
|
|
107
112
|
|
|
108
113
|
|
|
114
|
+
class ConcurrentClaimTestCase(TestHelperMixin, TransactionTestCase):
|
|
115
|
+
databases = {"default", "queue"}
|
|
116
|
+
|
|
117
|
+
@skipUnless(
|
|
118
|
+
connections["queue"].features.has_select_for_update_skip_locked,
|
|
119
|
+
"The queue database must support SELECT FOR UPDATE SKIP LOCKED",
|
|
120
|
+
)
|
|
121
|
+
def test_higher_priority_arrival_does_not_change_claimed_candidates(self):
|
|
122
|
+
"""#25: keep the selected jobs stable across insertion and retrieval."""
|
|
123
|
+
process = self.create_test_process()
|
|
124
|
+
original = self.create_job_in_queue("default", priority=0)
|
|
125
|
+
bulk_create = ClaimedExecutionQuerySet.bulk_create
|
|
126
|
+
|
|
127
|
+
def enqueue_higher_priority_job():
|
|
128
|
+
try:
|
|
129
|
+
return self.create_job_in_queue("default", priority=10).pk
|
|
130
|
+
finally:
|
|
131
|
+
connections.close_all()
|
|
132
|
+
|
|
133
|
+
def insert_claims_then_enqueue(queryset, *args, **kwargs):
|
|
134
|
+
result = bulk_create(queryset, *args, **kwargs)
|
|
135
|
+
# Commit an arrival on another connection after claims are inserted,
|
|
136
|
+
# but before claiming() retrieves them. No sleeps or timing races.
|
|
137
|
+
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
138
|
+
arrival = executor.submit(enqueue_higher_priority_job)
|
|
139
|
+
arrivals.append(arrival.result(timeout=10))
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
arrivals = []
|
|
143
|
+
with patch.object(
|
|
144
|
+
ClaimedExecutionQuerySet, "bulk_create", insert_claims_then_enqueue
|
|
145
|
+
):
|
|
146
|
+
claimed = ReadyExecution.objects.claim(["*"], 1, process.pk)
|
|
147
|
+
|
|
148
|
+
self.assertEqual([execution.job_id for execution in claimed], [original.pk])
|
|
149
|
+
self.assertFalse(
|
|
150
|
+
ReadyExecution.objects.filter(
|
|
151
|
+
job_id__in=ClaimedExecution.objects.values("job_id")
|
|
152
|
+
).exists()
|
|
153
|
+
)
|
|
154
|
+
self.assertEqual(
|
|
155
|
+
list(ReadyExecution.objects.values_list("job_id", flat=True)), arrivals
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Draining the remaining queue must not try to claim the original again.
|
|
159
|
+
next_claimed = ReadyExecution.objects.claim(["*"], 1, process.pk)
|
|
160
|
+
self.assertEqual([execution.job_id for execution in next_claimed], arrivals)
|
|
161
|
+
self.assertEqual(ReadyExecution.objects.claim(["*"], 1, process.pk), [])
|
|
162
|
+
self.assertEqual(ClaimedExecution.objects.count(), 2)
|
|
163
|
+
|
|
164
|
+
|
|
109
165
|
class ClaimedExecutionTestCase(TestHelperMixin, TestCase):
|
|
110
166
|
"""Tests for ClaimedExecution behavior."""
|
|
111
167
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from unittest import skipUnless
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
from django.db import connections
|
|
5
|
+
from django.test import SimpleTestCase, TransactionTestCase
|
|
6
|
+
|
|
7
|
+
from steady_queue.configuration import Configuration
|
|
8
|
+
from steady_queue.models import (
|
|
9
|
+
ClaimedExecution,
|
|
10
|
+
FailedExecution,
|
|
11
|
+
Job,
|
|
12
|
+
Process,
|
|
13
|
+
ReadyExecution,
|
|
14
|
+
)
|
|
15
|
+
from steady_queue.processes.base import Base
|
|
16
|
+
from steady_queue.processes.supervisor import Supervisor
|
|
17
|
+
from tests.dummy.tasks import dummy_task
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SupervisorForkSafetyTest(SimpleTestCase):
|
|
21
|
+
def build_supervisor(self) -> Supervisor:
|
|
22
|
+
options = Configuration.Options(
|
|
23
|
+
workers=[],
|
|
24
|
+
dispatchers=[],
|
|
25
|
+
recurring_tasks=[],
|
|
26
|
+
skip_recurring=True,
|
|
27
|
+
)
|
|
28
|
+
return Supervisor(Configuration(options))
|
|
29
|
+
|
|
30
|
+
def test_supervisor_start_resets_connections_before_forking(self):
|
|
31
|
+
supervisor = self.build_supervisor()
|
|
32
|
+
calls = []
|
|
33
|
+
|
|
34
|
+
supervisor.boot = lambda: calls.append("boot")
|
|
35
|
+
supervisor.reset_database_connections = lambda: calls.append("reset")
|
|
36
|
+
supervisor.start_processes = lambda: calls.append("start_processes")
|
|
37
|
+
supervisor.launch_maintenance_task = lambda: calls.append("launch_maintenance")
|
|
38
|
+
supervisor.supervise = lambda: calls.append("supervise")
|
|
39
|
+
|
|
40
|
+
supervisor.start()
|
|
41
|
+
|
|
42
|
+
self.assertEqual(
|
|
43
|
+
calls,
|
|
44
|
+
[
|
|
45
|
+
"boot",
|
|
46
|
+
"reset",
|
|
47
|
+
"start_processes",
|
|
48
|
+
"launch_maintenance",
|
|
49
|
+
"supervise",
|
|
50
|
+
],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@skipUnless(
|
|
55
|
+
connections["queue"].vendor == "postgresql",
|
|
56
|
+
"Requires PostgreSQL connection pooling on the queue database",
|
|
57
|
+
)
|
|
58
|
+
class SupervisorPostgreSQLForkTest(TransactionTestCase):
|
|
59
|
+
databases = {"default", "queue"}
|
|
60
|
+
|
|
61
|
+
def setUp(self):
|
|
62
|
+
options = Configuration.Options(
|
|
63
|
+
workers=[], dispatchers=[], recurring_tasks=[], skip_recurring=True
|
|
64
|
+
)
|
|
65
|
+
self.supervisor = Supervisor(Configuration(options))
|
|
66
|
+
self.supervisor.register()
|
|
67
|
+
self.worker_config = Configuration.Process(
|
|
68
|
+
kind="worker", attributes=Configuration.Worker()
|
|
69
|
+
)
|
|
70
|
+
self.connection = connections["queue"]
|
|
71
|
+
self.pool_options = self.connection.settings_dict["OPTIONS"]["pool"].copy()
|
|
72
|
+
self.addCleanup(self.supervisor.reset_database_connections)
|
|
73
|
+
|
|
74
|
+
def assert_reset_at_fork(self):
|
|
75
|
+
# Intercept only the OS fork; exercise real Django connections, psycopg
|
|
76
|
+
# pools, and replacement recovery up to the boundary children inherit.
|
|
77
|
+
self.assertIsNone(self.connection.connection)
|
|
78
|
+
self.assertEqual(self.connection.__class__._connection_pools, {})
|
|
79
|
+
self.assertEqual(
|
|
80
|
+
self.connection.settings_dict["OPTIONS"]["pool"], self.pool_options
|
|
81
|
+
)
|
|
82
|
+
return 12346
|
|
83
|
+
|
|
84
|
+
def test_start_process_resets_open_connection_and_pool_before_fork(self):
|
|
85
|
+
self.assertIsNotNone(self.connection.connection)
|
|
86
|
+
self.assertIn("queue", self.connection.__class__._connection_pools)
|
|
87
|
+
|
|
88
|
+
with patch(
|
|
89
|
+
"steady_queue.processes.supervisor.os.fork",
|
|
90
|
+
side_effect=self.assert_reset_at_fork,
|
|
91
|
+
) as fork:
|
|
92
|
+
self.supervisor.start_process(self.worker_config)
|
|
93
|
+
|
|
94
|
+
fork.assert_called_once_with()
|
|
95
|
+
self.assertIn(12346, self.supervisor.forks)
|
|
96
|
+
# Pooling remains usable by the parent after the reset.
|
|
97
|
+
self.assertTrue(Process.objects.filter(pk=self.supervisor.process.pk).exists())
|
|
98
|
+
self.assertIn("queue", self.connection.__class__._connection_pools)
|
|
99
|
+
|
|
100
|
+
def test_replacement_resets_connection_reopened_by_job_recovery(self):
|
|
101
|
+
old_worker = self.worker_config.instantiate()
|
|
102
|
+
self.addCleanup(old_worker.pool.shutdown)
|
|
103
|
+
registered_worker = Process.register(
|
|
104
|
+
kind="worker",
|
|
105
|
+
name=old_worker.name,
|
|
106
|
+
pid=12345,
|
|
107
|
+
hostname="test-host",
|
|
108
|
+
supervisor=self.supervisor.process,
|
|
109
|
+
)
|
|
110
|
+
job = Job.objects.enqueue(dummy_task, [], {})
|
|
111
|
+
ReadyExecution.objects.claim(["*"], 1, registered_worker.pk)
|
|
112
|
+
self.supervisor.forks[12345] = old_worker
|
|
113
|
+
self.supervisor.configured_processes[12345] = self.worker_config
|
|
114
|
+
self.supervisor.reset_database_connections()
|
|
115
|
+
|
|
116
|
+
with patch(
|
|
117
|
+
"steady_queue.processes.supervisor.os.fork",
|
|
118
|
+
side_effect=self.assert_reset_at_fork,
|
|
119
|
+
) as fork:
|
|
120
|
+
self.supervisor.replace_fork(12345, 11)
|
|
121
|
+
|
|
122
|
+
fork.assert_called_once_with()
|
|
123
|
+
self.assertNotIn(12345, self.supervisor.forks)
|
|
124
|
+
self.assertIn(12346, self.supervisor.forks)
|
|
125
|
+
self.assertFalse(ClaimedExecution.objects.filter(job=job).exists())
|
|
126
|
+
self.assertTrue(FailedExecution.objects.filter(job=job).exists())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ResetDatabaseConnectionsTest(SimpleTestCase):
|
|
130
|
+
def test_reset_connections_clears_psycopg_pool_cache_without_disabling_pooling(
|
|
131
|
+
self,
|
|
132
|
+
):
|
|
133
|
+
class FakePool:
|
|
134
|
+
def __init__(self):
|
|
135
|
+
self.closed = False
|
|
136
|
+
|
|
137
|
+
def close(self):
|
|
138
|
+
self.closed = True
|
|
139
|
+
|
|
140
|
+
class FakeConnection:
|
|
141
|
+
_connection_pools = {}
|
|
142
|
+
|
|
143
|
+
def __init__(self, engine: str):
|
|
144
|
+
self.settings_dict = {
|
|
145
|
+
"ENGINE": engine,
|
|
146
|
+
"OPTIONS": {"pool": {"min_size": 1, "max_size": 4}},
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
class FakeConnections(dict):
|
|
150
|
+
close_all_called = False
|
|
151
|
+
|
|
152
|
+
def __iter__(self):
|
|
153
|
+
return iter(self.keys())
|
|
154
|
+
|
|
155
|
+
def close_all(self):
|
|
156
|
+
self.close_all_called = True
|
|
157
|
+
|
|
158
|
+
pool_default = FakePool()
|
|
159
|
+
pool_queue = FakePool()
|
|
160
|
+
FakeConnection._connection_pools = {
|
|
161
|
+
"default": pool_default,
|
|
162
|
+
"queue": pool_queue,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
fake_connections = FakeConnections(
|
|
166
|
+
{
|
|
167
|
+
"default": FakeConnection("django.db.backends.postgresql"),
|
|
168
|
+
"queue": FakeConnection("django.db.backends.postgresql"),
|
|
169
|
+
"sqlite": FakeConnection("django.db.backends.sqlite3"),
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
with patch("steady_queue.processes.base.connections", fake_connections):
|
|
174
|
+
Base().reset_database_connections()
|
|
175
|
+
|
|
176
|
+
self.assertIn("pool", fake_connections["default"].settings_dict["OPTIONS"])
|
|
177
|
+
self.assertIn("pool", fake_connections["queue"].settings_dict["OPTIONS"])
|
|
178
|
+
self.assertIn("pool", fake_connections["sqlite"].settings_dict["OPTIONS"])
|
|
179
|
+
|
|
180
|
+
self.assertTrue(fake_connections.close_all_called)
|
|
181
|
+
self.assertTrue(pool_default.closed)
|
|
182
|
+
self.assertTrue(pool_queue.closed)
|
|
183
|
+
self.assertEqual(FakeConnection._connection_pools, {})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from datetime import timedelta
|
|
2
|
-
from unittest.mock import MagicMock
|
|
2
|
+
from unittest.mock import MagicMock, patch
|
|
3
3
|
|
|
4
4
|
from django.test import TestCase
|
|
5
5
|
from django.utils import timezone
|
|
@@ -272,11 +272,11 @@ class PoolPostNilProcessTest(TestCase):
|
|
|
272
272
|
|
|
273
273
|
def test_pool_post_does_not_access_execution_process(self):
|
|
274
274
|
"""Pool.post should not crash when execution.process is None."""
|
|
275
|
-
import time
|
|
276
|
-
|
|
277
275
|
from steady_queue.processes.pool import Pool
|
|
278
276
|
|
|
279
|
-
|
|
277
|
+
on_idle = MagicMock()
|
|
278
|
+
pool = Pool(size=1, on_idle=on_idle, worker_name="test-worker")
|
|
279
|
+
self.addCleanup(pool.executor.shutdown, wait=True)
|
|
280
280
|
|
|
281
281
|
execution = MagicMock()
|
|
282
282
|
execution.process = None
|
|
@@ -284,11 +284,25 @@ class PoolPostNilProcessTest(TestCase):
|
|
|
284
284
|
execution.job.class_name = "test_task"
|
|
285
285
|
execution.pk = 1
|
|
286
286
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
287
|
+
submit = pool.executor.submit
|
|
288
|
+
futures = []
|
|
289
|
+
|
|
290
|
+
def capture_future(*args, **kwargs):
|
|
291
|
+
future = submit(*args, **kwargs)
|
|
292
|
+
futures.append(future)
|
|
293
|
+
return future
|
|
294
|
+
|
|
295
|
+
with self.assertLogs("steady_queue", level="INFO") as logs:
|
|
296
|
+
with patch.object(pool.executor, "submit", capture_future):
|
|
297
|
+
pool.post(execution)
|
|
298
|
+
# Exceptions after perform() live in the Future. Checking only that
|
|
299
|
+
# perform() ran allowed the original #26 logging crash to pass.
|
|
300
|
+
futures[0].result(timeout=5)
|
|
292
301
|
|
|
293
|
-
# Verify perform was called successfully
|
|
294
302
|
execution.perform.assert_called_once()
|
|
303
|
+
self.assertEqual(
|
|
304
|
+
logs.output,
|
|
305
|
+
["INFO:steady_queue:test-worker completed job 1 test_task"],
|
|
306
|
+
)
|
|
307
|
+
self.assertEqual(pool.idle_threads, 1)
|
|
308
|
+
on_idle.assert_called_once_with()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from django.test import TestCase
|
|
2
|
+
from django.utils import timezone
|
|
3
|
+
|
|
4
|
+
from steady_queue.configuration import Configuration
|
|
5
|
+
from steady_queue.models import Job, RecurringExecution, RecurringTask
|
|
6
|
+
from tests.dummy.tasks import dummy_task
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RecurringExecutionTestCase(TestCase):
|
|
10
|
+
def test_enqueue_same_run_at_only_enqueues_once(self):
|
|
11
|
+
recurring_task = RecurringTask.from_configuration(
|
|
12
|
+
Configuration.RecurringTask(
|
|
13
|
+
key="test-recurring-task",
|
|
14
|
+
class_name="tests.dummy.tasks.dummy_task",
|
|
15
|
+
schedule="* * * * *",
|
|
16
|
+
arguments=dummy_task.serialize([], {}),
|
|
17
|
+
queue_name="default",
|
|
18
|
+
priority=0,
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
recurring_task.save()
|
|
22
|
+
|
|
23
|
+
run_at = timezone.now().replace(second=0, microsecond=0)
|
|
24
|
+
|
|
25
|
+
first_result = recurring_task.enqueue(run_at=run_at)
|
|
26
|
+
second_result = recurring_task.enqueue(run_at=run_at)
|
|
27
|
+
|
|
28
|
+
self.assertNotEqual(first_result, False)
|
|
29
|
+
self.assertFalse(second_result)
|
|
30
|
+
|
|
31
|
+
self.assertEqual(Job.objects.count(), 1)
|
|
32
|
+
self.assertEqual(RecurringExecution.objects.count(), 1)
|
|
33
|
+
|
|
34
|
+
recurring_execution = RecurringExecution.objects.get()
|
|
35
|
+
self.assertEqual(recurring_execution.task_id, recurring_task.key)
|
|
36
|
+
self.assertEqual(recurring_execution.run_at, run_at)
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
from unittest.mock import patch
|
|
2
|
-
|
|
3
|
-
from django.test import SimpleTestCase
|
|
4
|
-
|
|
5
|
-
from steady_queue.configuration import Configuration
|
|
6
|
-
from steady_queue.processes.base import Base
|
|
7
|
-
from steady_queue.processes.supervisor import Supervisor
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class SupervisorForkSafetyTest(SimpleTestCase):
|
|
11
|
-
def build_supervisor(self) -> Supervisor:
|
|
12
|
-
options = Configuration.Options(
|
|
13
|
-
workers=[],
|
|
14
|
-
dispatchers=[],
|
|
15
|
-
recurring_tasks=[],
|
|
16
|
-
skip_recurring=True,
|
|
17
|
-
)
|
|
18
|
-
return Supervisor(Configuration(options))
|
|
19
|
-
|
|
20
|
-
def test_supervisor_start_resets_connections_before_forking(self):
|
|
21
|
-
supervisor = self.build_supervisor()
|
|
22
|
-
calls = []
|
|
23
|
-
|
|
24
|
-
supervisor.boot = lambda: calls.append("boot")
|
|
25
|
-
supervisor.reset_database_connections = lambda: calls.append("reset")
|
|
26
|
-
supervisor.start_processes = lambda: calls.append("start_processes")
|
|
27
|
-
supervisor.launch_maintenance_task = lambda: calls.append("launch_maintenance")
|
|
28
|
-
supervisor.supervise = lambda: calls.append("supervise")
|
|
29
|
-
|
|
30
|
-
supervisor.start()
|
|
31
|
-
|
|
32
|
-
self.assertEqual(
|
|
33
|
-
calls,
|
|
34
|
-
[
|
|
35
|
-
"boot",
|
|
36
|
-
"reset",
|
|
37
|
-
"start_processes",
|
|
38
|
-
"launch_maintenance",
|
|
39
|
-
"supervise",
|
|
40
|
-
],
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
class ResetDatabaseConnectionsTest(SimpleTestCase):
|
|
45
|
-
def test_reset_connections_clears_psycopg_pool_cache_without_disabling_pooling(
|
|
46
|
-
self,
|
|
47
|
-
):
|
|
48
|
-
class FakePool:
|
|
49
|
-
def __init__(self):
|
|
50
|
-
self.closed = False
|
|
51
|
-
|
|
52
|
-
def close(self):
|
|
53
|
-
self.closed = True
|
|
54
|
-
|
|
55
|
-
class FakeConnection:
|
|
56
|
-
_connection_pools = {}
|
|
57
|
-
|
|
58
|
-
def __init__(self, engine: str):
|
|
59
|
-
self.settings_dict = {
|
|
60
|
-
"ENGINE": engine,
|
|
61
|
-
"OPTIONS": {"pool": {"min_size": 1, "max_size": 4}},
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
class FakeConnections(dict):
|
|
65
|
-
close_all_called = False
|
|
66
|
-
|
|
67
|
-
def __iter__(self):
|
|
68
|
-
return iter(self.keys())
|
|
69
|
-
|
|
70
|
-
def close_all(self):
|
|
71
|
-
self.close_all_called = True
|
|
72
|
-
|
|
73
|
-
pool_default = FakePool()
|
|
74
|
-
pool_queue = FakePool()
|
|
75
|
-
FakeConnection._connection_pools = {
|
|
76
|
-
"default": pool_default,
|
|
77
|
-
"queue": pool_queue,
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
fake_connections = FakeConnections(
|
|
81
|
-
{
|
|
82
|
-
"default": FakeConnection("django.db.backends.postgresql"),
|
|
83
|
-
"queue": FakeConnection("django.db.backends.postgresql"),
|
|
84
|
-
"sqlite": FakeConnection("django.db.backends.sqlite3"),
|
|
85
|
-
}
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
with patch("steady_queue.processes.base.connections", fake_connections):
|
|
89
|
-
Base().reset_database_connections()
|
|
90
|
-
|
|
91
|
-
self.assertIn("pool", fake_connections["default"].settings_dict["OPTIONS"])
|
|
92
|
-
self.assertIn("pool", fake_connections["queue"].settings_dict["OPTIONS"])
|
|
93
|
-
self.assertIn("pool", fake_connections["sqlite"].settings_dict["OPTIONS"])
|
|
94
|
-
|
|
95
|
-
self.assertTrue(fake_connections.close_all_called)
|
|
96
|
-
self.assertTrue(pool_default.closed)
|
|
97
|
-
self.assertTrue(pool_queue.closed)
|
|
98
|
-
self.assertEqual(FakeConnection._connection_pools, {})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|