steady-queue 0.1.8__tar.gz → 0.2.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.
- {steady_queue-0.1.8/steady_queue.egg-info → steady_queue-0.2.0}/PKG-INFO +6 -2
- {steady_queue-0.1.8 → steady_queue-0.2.0}/README.md +1 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/pyproject.toml +5 -1
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/__init__.py +1 -1
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/configuration.py +43 -0
- steady_queue-0.2.0/steady_queue/processes/base.py +98 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/supervisor.py +2 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0/steady_queue.egg-info}/PKG-INFO +6 -2
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue.egg-info/SOURCES.txt +1 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/tests/test_configuration.py +49 -0
- steady_queue-0.2.0/tests/test_fork_safety.py +98 -0
- steady_queue-0.1.8/steady_queue/processes/base.py +0 -102
- {steady_queue-0.1.8 → steady_queue-0.2.0}/LICENSE +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/setup.cfg +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/admin.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/app_executor.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/apps.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/arguments.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/backend.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/collections.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/concurrency.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/db_router.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/management/__init__.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/management/commands/__init__.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/management/commands/steady_queue.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/migrations/0001_initial.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/migrations/__init__.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/__init__.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/base.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/blocked_execution.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/claimed_execution.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/clearable.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/concurrency_controls.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/dispatching.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/executable.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/execution.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/executor.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/failed_execution.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/job.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/pause.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/process.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/prunable.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/queue.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/ready_execution.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/recurring_execution.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/recurring_task.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/retryable.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/schedulable.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/scheduled_execution.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/models/semaphore.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/__init__.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/concurrent.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/dispatcher.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/errors.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/interruptible.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/maintenance.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/pidfile.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/pidfiled.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/poller.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/pool.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/registrable.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/runnable.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/scheduler.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/signals.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/supervised.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/timer.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/processes/worker.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/queue_selector.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/recurring_schedule.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/recurring_task.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue/task.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue.egg-info/dependency_links.txt +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue.egg-info/requires.txt +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/steady_queue.egg-info/top_level.txt +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/tests/test_arguments.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/tests/test_backend.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/tests/test_concurrency_controls.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/tests/test_db_router.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/tests/test_executions.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/tests/test_job.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/tests/test_pidfile.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/tests/test_process_lifecycle.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/tests/test_queue_selector.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/tests/test_recurring_task.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/tests/test_semaphore.py +0 -0
- {steady_queue-0.1.8 → steady_queue-0.2.0}/tests/test_task.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: steady-queue
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
@@ -10,12 +10,15 @@ Project-URL: Documentation, https://steady-queue.readthedocs.io/en/latest/
|
|
|
10
10
|
Project-URL: Tracker, https://github.com/knifecake/steady-queue/issues
|
|
11
11
|
Project-URL: Releases, https://github.com/knifecake/steady-queue/releases
|
|
12
12
|
Keywords: django,tasks,async,jobs,queues
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
14
|
Classifier: Framework :: Django
|
|
14
15
|
Classifier: Framework :: Django :: 6.0
|
|
15
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
18
|
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
-
Classifier: Operating System ::
|
|
19
|
+
Classifier: Operating System :: MacOS
|
|
20
|
+
Classifier: Operating System :: POSIX
|
|
21
|
+
Classifier: Operating System :: Unix
|
|
19
22
|
Classifier: Natural Language :: English
|
|
20
23
|
Requires-Python: >=3.12
|
|
21
24
|
Description-Content-Type: text/markdown
|
|
@@ -63,6 +66,7 @@ The database backend exposed by Steady Queue doesn't support the following optio
|
|
|
63
66
|
result = task.return_value` will result in an error (even if the task has
|
|
64
67
|
completed processing). Instead, we recommend saving the results directly on
|
|
65
68
|
your database or file storage system if they need to be kept around.
|
|
69
|
+
- **Requires a POSIX system** due to the concurrency model based on `os.fork`. Steady Queue doesn't run on windows.
|
|
66
70
|
|
|
67
71
|
The Steady Queue backend advertises these limitations by [setting the
|
|
68
72
|
corresponding flags][django-tasks-flags] on its backend.
|
|
@@ -37,6 +37,7 @@ The database backend exposed by Steady Queue doesn't support the following optio
|
|
|
37
37
|
result = task.return_value` will result in an error (even if the task has
|
|
38
38
|
completed processing). Instead, we recommend saving the results directly on
|
|
39
39
|
your database or file storage system if they need to be kept around.
|
|
40
|
+
- **Requires a POSIX system** due to the concurrency model based on `os.fork`. Steady Queue doesn't run on windows.
|
|
40
41
|
|
|
41
42
|
The Steady Queue backend advertises these limitations by [setting the
|
|
42
43
|
corresponding flags][django-tasks-flags] on its backend.
|
|
@@ -10,13 +10,17 @@ authors = [
|
|
|
10
10
|
requires-python = ">=3.12"
|
|
11
11
|
keywords = ["django", "tasks", "async", "jobs", "queues"]
|
|
12
12
|
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
13
14
|
"Framework :: Django",
|
|
14
15
|
"Framework :: Django :: 6.0",
|
|
15
16
|
"Programming Language :: Python :: 3.12",
|
|
16
17
|
"Programming Language :: Python :: 3.13",
|
|
17
18
|
"Programming Language :: Python :: 3.14",
|
|
18
|
-
"Operating System ::
|
|
19
|
+
"Operating System :: MacOS",
|
|
20
|
+
"Operating System :: POSIX",
|
|
21
|
+
"Operating System :: Unix",
|
|
19
22
|
"Natural Language :: English",
|
|
23
|
+
|
|
20
24
|
]
|
|
21
25
|
dependencies = [
|
|
22
26
|
"crontab>=1.0.5",
|
|
@@ -3,9 +3,11 @@ from datetime import timedelta
|
|
|
3
3
|
from typing import Optional
|
|
4
4
|
|
|
5
5
|
from crontab import CronTab
|
|
6
|
+
from django.conf import settings
|
|
6
7
|
from django.core.exceptions import ValidationError
|
|
7
8
|
from django.utils.module_loading import import_string
|
|
8
9
|
|
|
10
|
+
from steady_queue.db_router import steady_queue_database_alias
|
|
9
11
|
from steady_queue.processes.base import Base
|
|
10
12
|
|
|
11
13
|
|
|
@@ -167,6 +169,7 @@ class Configuration:
|
|
|
167
169
|
def is_valid(self):
|
|
168
170
|
self.errors = []
|
|
169
171
|
self.errors.extend(self.validate_configured_processes())
|
|
172
|
+
self.errors.extend(self.validate_database_pool_size())
|
|
170
173
|
self.errors.extend(self.validate_recurring_tasks())
|
|
171
174
|
|
|
172
175
|
return len(self.errors) == 0
|
|
@@ -177,6 +180,39 @@ class Configuration:
|
|
|
177
180
|
|
|
178
181
|
return []
|
|
179
182
|
|
|
183
|
+
def validate_database_pool_size(self) -> list[ValidationError]:
|
|
184
|
+
# Match Solid Queue behavior by validating worker thread count against
|
|
185
|
+
# the queue DB connection pool size when a max_size is explicitly set.
|
|
186
|
+
if len(self.options.workers) == 0:
|
|
187
|
+
return []
|
|
188
|
+
|
|
189
|
+
db_alias = steady_queue_database_alias()
|
|
190
|
+
db_config = settings.DATABASES.get(db_alias, {})
|
|
191
|
+
|
|
192
|
+
if db_config.get("ENGINE") != "django.db.backends.postgresql":
|
|
193
|
+
return []
|
|
194
|
+
|
|
195
|
+
pool_options = db_config.get("OPTIONS", {}).get("pool")
|
|
196
|
+
if not isinstance(pool_options, dict):
|
|
197
|
+
return []
|
|
198
|
+
|
|
199
|
+
pool_max_size = pool_options.get("max_size")
|
|
200
|
+
if not isinstance(pool_max_size, int):
|
|
201
|
+
return []
|
|
202
|
+
|
|
203
|
+
if pool_max_size < self.estimated_number_of_threads:
|
|
204
|
+
return [
|
|
205
|
+
ValidationError(
|
|
206
|
+
"Steady Queue is configured to use "
|
|
207
|
+
f"{self.estimated_number_of_threads} threads but the "
|
|
208
|
+
f"database connection pool max_size for '{db_alias}' is "
|
|
209
|
+
f"{pool_max_size}. Increase "
|
|
210
|
+
f"DATABASES['{db_alias}']['OPTIONS']['pool']['max_size']."
|
|
211
|
+
)
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
return []
|
|
215
|
+
|
|
180
216
|
def validate_recurring_tasks(self) -> list[ValidationError]:
|
|
181
217
|
if self.skip_recurring:
|
|
182
218
|
return []
|
|
@@ -195,6 +231,13 @@ class Configuration:
|
|
|
195
231
|
|
|
196
232
|
return errors
|
|
197
233
|
|
|
234
|
+
@property
|
|
235
|
+
def estimated_number_of_threads(self) -> int:
|
|
236
|
+
# At most `threads` in each worker + 2 additional threads (worker loop
|
|
237
|
+
# and heartbeat), mirroring Solid Queue's sizing heuristic.
|
|
238
|
+
max_worker_threads = max((w.threads for w in self.options.workers), default=1)
|
|
239
|
+
return max_worker_threads + 2
|
|
240
|
+
|
|
198
241
|
@property
|
|
199
242
|
def skip_recurring(self) -> bool:
|
|
200
243
|
return self.options.skip_recurring or self.options.only_work
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import secrets
|
|
4
|
+
import socket
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django.db import connections
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("steady_queue")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Base:
|
|
13
|
+
name: str
|
|
14
|
+
stopped: bool = False
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self.name = self.generate_name()
|
|
18
|
+
self.stopped = False
|
|
19
|
+
|
|
20
|
+
def boot(self):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
def shutdown(self):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
def stop(self):
|
|
27
|
+
self.stopped = True
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def kind(self) -> str:
|
|
31
|
+
return self.__class__.__name__.lower()
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def pid(self) -> int:
|
|
35
|
+
return os.getpid()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def hostname(self) -> str:
|
|
39
|
+
return socket.gethostname()
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def metadata(self) -> dict[str, Any]:
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def is_stopped(self) -> bool:
|
|
47
|
+
return self.stopped
|
|
48
|
+
|
|
49
|
+
def generate_name(self) -> str:
|
|
50
|
+
return "-".join((self.kind, secrets.token_hex(10)))
|
|
51
|
+
|
|
52
|
+
def close_postgresql_connection_pools(self):
|
|
53
|
+
"""
|
|
54
|
+
Close and clear Django's class-level psycopg pool cache.
|
|
55
|
+
|
|
56
|
+
Django stores psycopg pools in DatabaseWrapper._connection_pools, so we
|
|
57
|
+
must clear those references to avoid inheriting stale pools across fork.
|
|
58
|
+
"""
|
|
59
|
+
closed_pool_maps: set[int] = set()
|
|
60
|
+
|
|
61
|
+
for alias in connections:
|
|
62
|
+
connection = connections[alias]
|
|
63
|
+
if (
|
|
64
|
+
connection.settings_dict.get("ENGINE")
|
|
65
|
+
!= "django.db.backends.postgresql"
|
|
66
|
+
):
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
pool_map = getattr(connection.__class__, "_connection_pools", None)
|
|
70
|
+
if not isinstance(pool_map, dict) or id(pool_map) in closed_pool_maps:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
for pool_alias, pool in list(pool_map.items()):
|
|
74
|
+
try:
|
|
75
|
+
pool.close()
|
|
76
|
+
logger.debug(
|
|
77
|
+
"%(name)s closed psycopg pool for '%(alias)s'",
|
|
78
|
+
{"name": self.name, "alias": pool_alias},
|
|
79
|
+
)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.debug(
|
|
82
|
+
"%(name)s failed to close pool for '%(alias)s': %(e)s",
|
|
83
|
+
{"name": self.name, "alias": pool_alias, "e": e},
|
|
84
|
+
)
|
|
85
|
+
finally:
|
|
86
|
+
pool_map.pop(pool_alias, None)
|
|
87
|
+
|
|
88
|
+
closed_pool_maps.add(id(pool_map))
|
|
89
|
+
|
|
90
|
+
def reset_database_connections(self):
|
|
91
|
+
"""
|
|
92
|
+
Reset database connections for forked processes.
|
|
93
|
+
|
|
94
|
+
This closes all current connections and clears Django's class-level
|
|
95
|
+
psycopg pool cache so child processes don't inherit parent pool state.
|
|
96
|
+
"""
|
|
97
|
+
connections.close_all()
|
|
98
|
+
self.close_postgresql_connection_pools()
|
|
@@ -43,6 +43,8 @@ class Supervisor(Maintenance, Signals, Pidfiled, Registrable, Interruptible, Bas
|
|
|
43
43
|
logger.info("starting supervisor with PID %(pid)d", {"pid": self.pid})
|
|
44
44
|
try:
|
|
45
45
|
self.boot()
|
|
46
|
+
# Fork only after resetting DB state (connections + psycopg pools).
|
|
47
|
+
self.reset_database_connections()
|
|
46
48
|
self.start_processes()
|
|
47
49
|
self.launch_maintenance_task()
|
|
48
50
|
except SystemExit:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: steady-queue
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
@@ -10,12 +10,15 @@ Project-URL: Documentation, https://steady-queue.readthedocs.io/en/latest/
|
|
|
10
10
|
Project-URL: Tracker, https://github.com/knifecake/steady-queue/issues
|
|
11
11
|
Project-URL: Releases, https://github.com/knifecake/steady-queue/releases
|
|
12
12
|
Keywords: django,tasks,async,jobs,queues
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
14
|
Classifier: Framework :: Django
|
|
14
15
|
Classifier: Framework :: Django :: 6.0
|
|
15
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
18
|
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
-
Classifier: Operating System ::
|
|
19
|
+
Classifier: Operating System :: MacOS
|
|
20
|
+
Classifier: Operating System :: POSIX
|
|
21
|
+
Classifier: Operating System :: Unix
|
|
19
22
|
Classifier: Natural Language :: English
|
|
20
23
|
Requires-Python: >=3.12
|
|
21
24
|
Description-Content-Type: text/markdown
|
|
@@ -63,6 +66,7 @@ The database backend exposed by Steady Queue doesn't support the following optio
|
|
|
63
66
|
result = task.return_value` will result in an error (even if the task has
|
|
64
67
|
completed processing). Instead, we recommend saving the results directly on
|
|
65
68
|
your database or file storage system if they need to be kept around.
|
|
69
|
+
- **Requires a POSIX system** due to the concurrency model based on `os.fork`. Steady Queue doesn't run on windows.
|
|
66
70
|
|
|
67
71
|
The Steady Queue backend advertises these limitations by [setting the
|
|
68
72
|
corresponding flags][django-tasks-flags] on its backend.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import warnings
|
|
1
2
|
from datetime import timedelta
|
|
2
3
|
|
|
3
4
|
from django.core.exceptions import ValidationError
|
|
@@ -153,6 +154,54 @@ class ConfigurationValidationTestCase(SimpleTestCase):
|
|
|
153
154
|
self.assertGreater(len(config.errors), 0)
|
|
154
155
|
self.assertIn("No processes configured", str(config.errors[0]))
|
|
155
156
|
|
|
157
|
+
def test_small_postgres_pool_fails_validation(self):
|
|
158
|
+
"""Configured worker threads must fit in postgres pool max_size."""
|
|
159
|
+
options = Configuration.Options(workers=[Configuration.Worker(threads=3)])
|
|
160
|
+
config = Configuration(options)
|
|
161
|
+
|
|
162
|
+
with warnings.catch_warnings():
|
|
163
|
+
warnings.filterwarnings(
|
|
164
|
+
"ignore",
|
|
165
|
+
message="Overriding setting DATABASES can lead to unexpected behavior.",
|
|
166
|
+
category=UserWarning,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
with self.settings(
|
|
170
|
+
DATABASES={
|
|
171
|
+
"queue": {
|
|
172
|
+
"ENGINE": "django.db.backends.postgresql",
|
|
173
|
+
"OPTIONS": {"pool": {"min_size": 1, "max_size": 4}},
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
):
|
|
177
|
+
self.assertFalse(config.is_valid)
|
|
178
|
+
|
|
179
|
+
self.assertTrue(
|
|
180
|
+
any("pool max_size" in error.message for error in config.errors)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def test_sufficient_postgres_pool_passes_validation(self):
|
|
184
|
+
"""Validation passes when postgres pool max_size is large enough."""
|
|
185
|
+
options = Configuration.Options(workers=[Configuration.Worker(threads=3)])
|
|
186
|
+
config = Configuration(options)
|
|
187
|
+
|
|
188
|
+
with warnings.catch_warnings():
|
|
189
|
+
warnings.filterwarnings(
|
|
190
|
+
"ignore",
|
|
191
|
+
message="Overriding setting DATABASES can lead to unexpected behavior.",
|
|
192
|
+
category=UserWarning,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
with self.settings(
|
|
196
|
+
DATABASES={
|
|
197
|
+
"queue": {
|
|
198
|
+
"ENGINE": "django.db.backends.postgresql",
|
|
199
|
+
"OPTIONS": {"pool": {"min_size": 1, "max_size": 5}},
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
):
|
|
203
|
+
self.assertTrue(config.is_valid)
|
|
204
|
+
|
|
156
205
|
def test_invalid_recurring_task_schedule_fails_validation(self):
|
|
157
206
|
"""Invalid cron schedule in recurring task fails validation."""
|
|
158
207
|
invalid_task = Configuration.RecurringTask(
|
|
@@ -0,0 +1,98 @@
|
|
|
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, {})
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import secrets
|
|
3
|
-
import socket
|
|
4
|
-
from typing import Any
|
|
5
|
-
|
|
6
|
-
from django.db import connections
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class Base:
|
|
10
|
-
name: str
|
|
11
|
-
stopped: bool = False
|
|
12
|
-
|
|
13
|
-
def __init__(self):
|
|
14
|
-
self.name = self.generate_name()
|
|
15
|
-
self.stopped = False
|
|
16
|
-
|
|
17
|
-
def boot(self):
|
|
18
|
-
pass
|
|
19
|
-
|
|
20
|
-
def shutdown(self):
|
|
21
|
-
pass
|
|
22
|
-
|
|
23
|
-
def stop(self):
|
|
24
|
-
self.stopped = True
|
|
25
|
-
|
|
26
|
-
@property
|
|
27
|
-
def kind(self) -> str:
|
|
28
|
-
return self.__class__.__name__.lower()
|
|
29
|
-
|
|
30
|
-
@property
|
|
31
|
-
def pid(self) -> int:
|
|
32
|
-
return os.getpid()
|
|
33
|
-
|
|
34
|
-
@property
|
|
35
|
-
def hostname(self) -> str:
|
|
36
|
-
return socket.gethostname()
|
|
37
|
-
|
|
38
|
-
@property
|
|
39
|
-
def metadata(self) -> dict[str, Any]:
|
|
40
|
-
return {}
|
|
41
|
-
|
|
42
|
-
@property
|
|
43
|
-
def is_stopped(self) -> bool:
|
|
44
|
-
return self.stopped
|
|
45
|
-
|
|
46
|
-
def generate_name(self) -> str:
|
|
47
|
-
return "-".join((self.kind, secrets.token_hex(10)))
|
|
48
|
-
|
|
49
|
-
def disable_connection_pooling(self):
|
|
50
|
-
"""
|
|
51
|
-
Disable connection pooling for steady_queue processes.
|
|
52
|
-
|
|
53
|
-
Connection pooling with psycopg doesn't work with forked processes.
|
|
54
|
-
This method removes pool configuration from database settings to prevent
|
|
55
|
-
pool-related errors in steady_queue workers.
|
|
56
|
-
"""
|
|
57
|
-
import logging
|
|
58
|
-
|
|
59
|
-
from django.conf import settings
|
|
60
|
-
|
|
61
|
-
logger = logging.getLogger("steady_queue")
|
|
62
|
-
|
|
63
|
-
# Disable pooling in database configuration
|
|
64
|
-
if hasattr(settings, "DATABASES"):
|
|
65
|
-
for alias, db_config in settings.DATABASES.items():
|
|
66
|
-
if db_config.get("ENGINE") == "django.db.backends.postgresql":
|
|
67
|
-
# Remove pool configuration if it exists
|
|
68
|
-
options = db_config.setdefault("OPTIONS", {})
|
|
69
|
-
if "pool" in options:
|
|
70
|
-
logger.info(
|
|
71
|
-
"%(name)s disabling connection pooling for database '%(alias)s'",
|
|
72
|
-
{"name": self.name, "alias": alias},
|
|
73
|
-
)
|
|
74
|
-
del options["pool"]
|
|
75
|
-
|
|
76
|
-
# Also disable on any existing connections
|
|
77
|
-
for alias in connections:
|
|
78
|
-
connection = connections[alias]
|
|
79
|
-
if hasattr(connection, "pool") and connection.pool is not None:
|
|
80
|
-
try:
|
|
81
|
-
connection.pool.close()
|
|
82
|
-
connection.pool = None
|
|
83
|
-
logger.debug(
|
|
84
|
-
"%(name)s removed existing pool for '%(alias)s'",
|
|
85
|
-
{"name": self.name, "alias": alias},
|
|
86
|
-
)
|
|
87
|
-
except Exception as e:
|
|
88
|
-
logger.debug(
|
|
89
|
-
"%(name)s failed to close existing pool for '%(alias)s': %(e)s",
|
|
90
|
-
{"name": self.name, "alias": alias, "e": e},
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
def reset_database_connections(self):
|
|
94
|
-
"""
|
|
95
|
-
Reset database connections for forked processes.
|
|
96
|
-
|
|
97
|
-
This disables connection pooling and resets connection state to prevent
|
|
98
|
-
issues with shared connections between parent and child processes.
|
|
99
|
-
"""
|
|
100
|
-
self.disable_connection_pooling()
|
|
101
|
-
|
|
102
|
-
connections.close_all()
|
|
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
|