steady-queue 0.1.7__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.
Files changed (86) hide show
  1. {steady_queue-0.1.7/steady_queue.egg-info → steady_queue-0.2.0}/PKG-INFO +7 -4
  2. {steady_queue-0.1.7 → steady_queue-0.2.0}/README.md +2 -2
  3. {steady_queue-0.1.7 → steady_queue-0.2.0}/pyproject.toml +5 -1
  4. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/__init__.py +2 -2
  5. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/configuration.py +43 -0
  6. steady_queue-0.2.0/steady_queue/processes/base.py +98 -0
  7. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/pidfile.py +5 -0
  8. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/supervisor.py +2 -0
  9. {steady_queue-0.1.7 → steady_queue-0.2.0/steady_queue.egg-info}/PKG-INFO +7 -4
  10. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue.egg-info/SOURCES.txt +2 -0
  11. {steady_queue-0.1.7 → steady_queue-0.2.0}/tests/test_configuration.py +49 -0
  12. steady_queue-0.2.0/tests/test_fork_safety.py +98 -0
  13. steady_queue-0.2.0/tests/test_pidfile.py +42 -0
  14. steady_queue-0.1.7/steady_queue/processes/base.py +0 -102
  15. {steady_queue-0.1.7 → steady_queue-0.2.0}/LICENSE +0 -0
  16. {steady_queue-0.1.7 → steady_queue-0.2.0}/setup.cfg +0 -0
  17. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/admin.py +0 -0
  18. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/app_executor.py +0 -0
  19. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/apps.py +0 -0
  20. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/arguments.py +0 -0
  21. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/backend.py +0 -0
  22. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/collections.py +0 -0
  23. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/concurrency.py +0 -0
  24. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/db_router.py +0 -0
  25. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/management/__init__.py +0 -0
  26. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/management/commands/__init__.py +0 -0
  27. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/management/commands/steady_queue.py +0 -0
  28. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/migrations/0001_initial.py +0 -0
  29. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/migrations/__init__.py +0 -0
  30. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/__init__.py +0 -0
  31. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/base.py +0 -0
  32. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/blocked_execution.py +0 -0
  33. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/claimed_execution.py +0 -0
  34. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/clearable.py +0 -0
  35. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/concurrency_controls.py +0 -0
  36. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/dispatching.py +0 -0
  37. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/executable.py +0 -0
  38. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/execution.py +0 -0
  39. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/executor.py +0 -0
  40. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/failed_execution.py +0 -0
  41. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/job.py +0 -0
  42. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/pause.py +0 -0
  43. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/process.py +0 -0
  44. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/prunable.py +0 -0
  45. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/queue.py +0 -0
  46. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/ready_execution.py +0 -0
  47. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/recurring_execution.py +0 -0
  48. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/recurring_task.py +0 -0
  49. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/retryable.py +0 -0
  50. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/schedulable.py +0 -0
  51. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/scheduled_execution.py +0 -0
  52. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/models/semaphore.py +0 -0
  53. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/__init__.py +0 -0
  54. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/concurrent.py +0 -0
  55. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/dispatcher.py +0 -0
  56. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/errors.py +0 -0
  57. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/interruptible.py +0 -0
  58. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/maintenance.py +0 -0
  59. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/pidfiled.py +0 -0
  60. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/poller.py +0 -0
  61. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/pool.py +0 -0
  62. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/registrable.py +0 -0
  63. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/runnable.py +0 -0
  64. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/scheduler.py +0 -0
  65. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/signals.py +0 -0
  66. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/supervised.py +0 -0
  67. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/timer.py +0 -0
  68. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/processes/worker.py +0 -0
  69. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/queue_selector.py +0 -0
  70. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/recurring_schedule.py +0 -0
  71. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/recurring_task.py +0 -0
  72. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue/task.py +0 -0
  73. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue.egg-info/dependency_links.txt +0 -0
  74. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue.egg-info/requires.txt +0 -0
  75. {steady_queue-0.1.7 → steady_queue-0.2.0}/steady_queue.egg-info/top_level.txt +0 -0
  76. {steady_queue-0.1.7 → steady_queue-0.2.0}/tests/test_arguments.py +0 -0
  77. {steady_queue-0.1.7 → steady_queue-0.2.0}/tests/test_backend.py +0 -0
  78. {steady_queue-0.1.7 → steady_queue-0.2.0}/tests/test_concurrency_controls.py +0 -0
  79. {steady_queue-0.1.7 → steady_queue-0.2.0}/tests/test_db_router.py +0 -0
  80. {steady_queue-0.1.7 → steady_queue-0.2.0}/tests/test_executions.py +0 -0
  81. {steady_queue-0.1.7 → steady_queue-0.2.0}/tests/test_job.py +0 -0
  82. {steady_queue-0.1.7 → steady_queue-0.2.0}/tests/test_process_lifecycle.py +0 -0
  83. {steady_queue-0.1.7 → steady_queue-0.2.0}/tests/test_queue_selector.py +0 -0
  84. {steady_queue-0.1.7 → steady_queue-0.2.0}/tests/test_recurring_task.py +0 -0
  85. {steady_queue-0.1.7 → steady_queue-0.2.0}/tests/test_semaphore.py +0 -0
  86. {steady_queue-0.1.7 → 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.1.7
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 :: OS Independent
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.
@@ -505,8 +509,7 @@ There are several settings that control how Steady Queue works that you can set
505
509
  requesting immediate termination—defaults to 5 seconds.
506
510
  - `supervisor_pidfile`: path to a pidfile that the supervisor will create when
507
511
  booting to prevent running more than one supervisor in the same host, or in
508
- case you want to use it for a health check. It's set to
509
- `tmp/pids/steady_queue_supervisor.pid` by default.
512
+ case you want to use it for a health check. It's `None` by default.
510
513
  - `preserve_finished_jobs`: whether to keep finished jobs in the
511
514
  `steady_queue_jobs` table—defaults to `True`.
512
515
  - `clear_finished_jobs_after`: period to keep finished jobs around, in case
@@ -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.
@@ -479,8 +480,7 @@ There are several settings that control how Steady Queue works that you can set
479
480
  requesting immediate termination—defaults to 5 seconds.
480
481
  - `supervisor_pidfile`: path to a pidfile that the supervisor will create when
481
482
  booting to prevent running more than one supervisor in the same host, or in
482
- case you want to use it for a health check. It's set to
483
- `tmp/pids/steady_queue_supervisor.pid` by default.
483
+ case you want to use it for a health check. It's `None` by default.
484
484
  - `preserve_finished_jobs`: whether to keep finished jobs in the
485
485
  `steady_queue_jobs` table—defaults to `True`.
486
486
  - `clear_finished_jobs_after`: period to keep finished jobs around, in case
@@ -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 :: OS Independent",
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",
@@ -1,7 +1,7 @@
1
1
  from datetime import timedelta
2
2
  from typing import Optional
3
3
 
4
- VERSION = (0, 1, 7)
4
+ VERSION = (0, 2, 0)
5
5
 
6
6
  __version__ = ".".join(map(str, VERSION))
7
7
 
@@ -17,6 +17,6 @@ clear_finished_jobs_after: timedelta = timedelta(days=1)
17
17
 
18
18
  default_concurrency_control_period: timedelta = timedelta(minutes=3)
19
19
 
20
- supervisor_pidfile: Optional[str] = "tmp/pids/steady_queue_supervisor.pid"
20
+ supervisor_pidfile: Optional[str] = None
21
21
 
22
22
  database: str = "default"
@@ -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()
@@ -1,3 +1,4 @@
1
+ import atexit
1
2
  import os
2
3
 
3
4
 
@@ -9,6 +10,7 @@ class Pidfile:
9
10
  def setup(self):
10
11
  self.check_status()
11
12
  self.write_file()
13
+ self.set_at_exit_hook()
12
14
 
13
15
  def delete(self):
14
16
  self.delete_file()
@@ -36,6 +38,9 @@ class Pidfile:
36
38
  self.check_status()
37
39
  self.write_file()
38
40
 
41
+ def set_at_exit_hook(self):
42
+ atexit.register(lambda: self.delete() if os.getpid() == self.pid else None)
43
+
39
44
  def delete_file(self):
40
45
  try:
41
46
  os.unlink(self.path)
@@ -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.1.7
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 :: OS Independent
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.
@@ -505,8 +509,7 @@ There are several settings that control how Steady Queue works that you can set
505
509
  requesting immediate termination—defaults to 5 seconds.
506
510
  - `supervisor_pidfile`: path to a pidfile that the supervisor will create when
507
511
  booting to prevent running more than one supervisor in the same host, or in
508
- case you want to use it for a health check. It's set to
509
- `tmp/pids/steady_queue_supervisor.pid` by default.
512
+ case you want to use it for a health check. It's `None` by default.
510
513
  - `preserve_finished_jobs`: whether to keep finished jobs in the
511
514
  `steady_queue_jobs` table—defaults to `True`.
512
515
  - `clear_finished_jobs_after`: period to keep finished jobs around, in case
@@ -73,7 +73,9 @@ tests/test_concurrency_controls.py
73
73
  tests/test_configuration.py
74
74
  tests/test_db_router.py
75
75
  tests/test_executions.py
76
+ tests/test_fork_safety.py
76
77
  tests/test_job.py
78
+ tests/test_pidfile.py
77
79
  tests/test_process_lifecycle.py
78
80
  tests/test_queue_selector.py
79
81
  tests/test_recurring_task.py
@@ -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, {})
@@ -0,0 +1,42 @@
1
+ import os
2
+ from tempfile import TemporaryDirectory
3
+ from unittest.mock import patch
4
+
5
+ from django.test import SimpleTestCase
6
+
7
+ import steady_queue
8
+ from steady_queue.processes.pidfile import Pidfile
9
+
10
+
11
+ class PidfileDefaultsTestCase(SimpleTestCase):
12
+ def test_supervisor_pidfile_defaults_to_none(self):
13
+ self.assertIsNone(steady_queue.supervisor_pidfile)
14
+
15
+
16
+ class PidfileLifecycleTestCase(SimpleTestCase):
17
+ def test_setup_registers_atexit_cleanup_hook(self):
18
+ with TemporaryDirectory() as temp_dir, patch("atexit.register") as register:
19
+ path = os.path.join(temp_dir, "pids", "steady_queue_supervisor.pid")
20
+
21
+ pidfile = Pidfile(path)
22
+ pidfile.setup()
23
+
24
+ register.assert_called_once()
25
+ self.assertTrue(os.path.exists(path))
26
+ with open(path) as f:
27
+ self.assertEqual(str(os.getpid()), f.read().strip())
28
+
29
+ def test_setup_replaces_stale_pidfile_for_dead_process(self):
30
+ with TemporaryDirectory() as temp_dir:
31
+ path = os.path.join(temp_dir, "pids", "steady_queue_supervisor.pid")
32
+
33
+ os.makedirs(os.path.dirname(path), exist_ok=True)
34
+ with open(path, "w") as f:
35
+ f.write("999999")
36
+
37
+ with patch("atexit.register"):
38
+ pidfile = Pidfile(path)
39
+ pidfile.setup()
40
+
41
+ with open(path) as f:
42
+ self.assertEqual(str(os.getpid()), f.read().strip())
@@ -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