django-nativemojo 0.1.10__py3-none-any.whl → 0.1.16__py3-none-any.whl

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 (276) hide show
  1. django_nativemojo-0.1.16.dist-info/METADATA +138 -0
  2. django_nativemojo-0.1.16.dist-info/RECORD +302 -0
  3. mojo/__init__.py +1 -1
  4. mojo/apps/account/management/__init__.py +5 -0
  5. mojo/apps/account/management/commands/__init__.py +6 -0
  6. mojo/apps/account/management/commands/serializer_admin.py +651 -0
  7. mojo/apps/account/migrations/0004_user_avatar.py +20 -0
  8. mojo/apps/account/migrations/0005_group_last_activity.py +18 -0
  9. mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
  10. mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
  11. mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
  12. mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
  13. mojo/apps/account/migrations/0010_group_avatar.py +20 -0
  14. mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
  15. mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
  16. mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
  17. mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
  18. mojo/apps/account/models/__init__.py +2 -0
  19. mojo/apps/account/models/device.py +281 -0
  20. mojo/apps/account/models/group.py +319 -15
  21. mojo/apps/account/models/member.py +29 -5
  22. mojo/apps/account/models/push/__init__.py +4 -0
  23. mojo/apps/account/models/push/config.py +112 -0
  24. mojo/apps/account/models/push/delivery.py +93 -0
  25. mojo/apps/account/models/push/device.py +66 -0
  26. mojo/apps/account/models/push/template.py +99 -0
  27. mojo/apps/account/models/user.py +369 -19
  28. mojo/apps/account/rest/__init__.py +2 -0
  29. mojo/apps/account/rest/device.py +39 -0
  30. mojo/apps/account/rest/group.py +9 -0
  31. mojo/apps/account/rest/push.py +187 -0
  32. mojo/apps/account/rest/user.py +100 -6
  33. mojo/apps/account/services/__init__.py +1 -0
  34. mojo/apps/account/services/push.py +363 -0
  35. mojo/apps/aws/migrations/0001_initial.py +206 -0
  36. mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
  37. mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
  38. mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
  39. mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
  40. mojo/apps/aws/models/__init__.py +19 -0
  41. mojo/apps/aws/models/email_attachment.py +99 -0
  42. mojo/apps/aws/models/email_domain.py +218 -0
  43. mojo/apps/aws/models/email_template.py +132 -0
  44. mojo/apps/aws/models/incoming_email.py +197 -0
  45. mojo/apps/aws/models/mailbox.py +288 -0
  46. mojo/apps/aws/models/sent_message.py +175 -0
  47. mojo/apps/aws/rest/__init__.py +7 -0
  48. mojo/apps/aws/rest/email.py +33 -0
  49. mojo/apps/aws/rest/email_ops.py +183 -0
  50. mojo/apps/aws/rest/messages.py +32 -0
  51. mojo/apps/aws/rest/s3.py +64 -0
  52. mojo/apps/aws/rest/send.py +101 -0
  53. mojo/apps/aws/rest/sns.py +403 -0
  54. mojo/apps/aws/rest/templates.py +19 -0
  55. mojo/apps/aws/services/__init__.py +32 -0
  56. mojo/apps/aws/services/email.py +390 -0
  57. mojo/apps/aws/services/email_ops.py +548 -0
  58. mojo/apps/docit/__init__.py +6 -0
  59. mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
  60. mojo/apps/docit/markdown_plugins/toc.py +12 -0
  61. mojo/apps/docit/migrations/0001_initial.py +113 -0
  62. mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
  63. mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
  64. mojo/apps/docit/models/__init__.py +17 -0
  65. mojo/apps/docit/models/asset.py +231 -0
  66. mojo/apps/docit/models/book.py +227 -0
  67. mojo/apps/docit/models/page.py +319 -0
  68. mojo/apps/docit/models/page_revision.py +203 -0
  69. mojo/apps/docit/rest/__init__.py +10 -0
  70. mojo/apps/docit/rest/asset.py +17 -0
  71. mojo/apps/docit/rest/book.py +22 -0
  72. mojo/apps/docit/rest/page.py +22 -0
  73. mojo/apps/docit/rest/page_revision.py +17 -0
  74. mojo/apps/docit/services/__init__.py +11 -0
  75. mojo/apps/docit/services/docit.py +315 -0
  76. mojo/apps/docit/services/markdown.py +44 -0
  77. mojo/apps/fileman/README.md +8 -8
  78. mojo/apps/fileman/backends/base.py +76 -70
  79. mojo/apps/fileman/backends/filesystem.py +86 -86
  80. mojo/apps/fileman/backends/s3.py +409 -108
  81. mojo/apps/fileman/migrations/0001_initial.py +106 -0
  82. mojo/apps/fileman/migrations/0002_filemanager_parent_alter_filemanager_max_file_size.py +24 -0
  83. mojo/apps/fileman/migrations/0003_remove_file_fileman_fil_upload__c4bc35_idx_and_more.py +25 -0
  84. mojo/apps/fileman/migrations/0004_remove_file_original_filename_and_more.py +39 -0
  85. mojo/apps/fileman/migrations/0005_alter_file_upload_token.py +18 -0
  86. mojo/apps/fileman/migrations/0006_file_download_url_filemanager_forever_urls.py +23 -0
  87. mojo/apps/fileman/migrations/0007_remove_filemanager_forever_urls_and_more.py +22 -0
  88. mojo/apps/fileman/migrations/0008_file_category.py +18 -0
  89. mojo/apps/fileman/migrations/0009_rename_file_path_file_storage_file_path.py +18 -0
  90. mojo/apps/fileman/migrations/0010_filerendition.py +33 -0
  91. mojo/apps/fileman/migrations/0011_alter_filerendition_original_file.py +19 -0
  92. mojo/apps/fileman/models/__init__.py +1 -5
  93. mojo/apps/fileman/models/file.py +240 -58
  94. mojo/apps/fileman/models/manager.py +427 -31
  95. mojo/apps/fileman/models/rendition.py +118 -0
  96. mojo/apps/fileman/renderer/__init__.py +111 -0
  97. mojo/apps/fileman/renderer/audio.py +403 -0
  98. mojo/apps/fileman/renderer/base.py +205 -0
  99. mojo/apps/fileman/renderer/document.py +404 -0
  100. mojo/apps/fileman/renderer/image.py +222 -0
  101. mojo/apps/fileman/renderer/utils.py +297 -0
  102. mojo/apps/fileman/renderer/video.py +304 -0
  103. mojo/apps/fileman/rest/__init__.py +1 -18
  104. mojo/apps/fileman/rest/upload.py +22 -32
  105. mojo/apps/fileman/signals.py +58 -0
  106. mojo/apps/fileman/tasks.py +254 -0
  107. mojo/apps/fileman/utils/__init__.py +40 -16
  108. mojo/apps/incident/migrations/0005_incidenthistory.py +39 -0
  109. mojo/apps/incident/migrations/0006_alter_incident_state.py +18 -0
  110. mojo/apps/incident/migrations/0007_event_uid.py +18 -0
  111. mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
  112. mojo/apps/incident/migrations/0009_incident_status.py +18 -0
  113. mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
  114. mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
  115. mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
  116. mojo/apps/incident/models/__init__.py +2 -0
  117. mojo/apps/incident/models/event.py +35 -0
  118. mojo/apps/incident/models/history.py +36 -0
  119. mojo/apps/incident/models/incident.py +3 -1
  120. mojo/apps/incident/models/ticket.py +62 -0
  121. mojo/apps/incident/reporter.py +21 -1
  122. mojo/apps/incident/rest/__init__.py +1 -0
  123. mojo/apps/incident/rest/event.py +7 -1
  124. mojo/apps/incident/rest/ticket.py +43 -0
  125. mojo/apps/jobs/__init__.py +489 -0
  126. mojo/apps/jobs/adapters.py +24 -0
  127. mojo/apps/jobs/cli.py +616 -0
  128. mojo/apps/jobs/daemon.py +370 -0
  129. mojo/apps/jobs/examples/sample_jobs.py +376 -0
  130. mojo/apps/jobs/examples/webhook_examples.py +203 -0
  131. mojo/apps/jobs/handlers/__init__.py +5 -0
  132. mojo/apps/jobs/handlers/webhook.py +317 -0
  133. mojo/apps/jobs/job_engine.py +734 -0
  134. mojo/apps/jobs/keys.py +203 -0
  135. mojo/apps/jobs/local_queue.py +363 -0
  136. mojo/apps/jobs/management/__init__.py +3 -0
  137. mojo/apps/jobs/management/commands/__init__.py +3 -0
  138. mojo/apps/jobs/manager.py +1327 -0
  139. mojo/apps/jobs/migrations/0001_initial.py +97 -0
  140. mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
  141. mojo/apps/jobs/models/__init__.py +6 -0
  142. mojo/apps/jobs/models/job.py +441 -0
  143. mojo/apps/jobs/rest/__init__.py +2 -0
  144. mojo/apps/jobs/rest/control.py +466 -0
  145. mojo/apps/jobs/rest/jobs.py +421 -0
  146. mojo/apps/jobs/scheduler.py +571 -0
  147. mojo/apps/jobs/services/__init__.py +6 -0
  148. mojo/apps/jobs/services/job_actions.py +465 -0
  149. mojo/apps/jobs/settings.py +209 -0
  150. mojo/apps/logit/migrations/0004_alter_log_level.py +18 -0
  151. mojo/apps/logit/models/log.py +7 -1
  152. mojo/apps/metrics/__init__.py +8 -1
  153. mojo/apps/metrics/redis_metrics.py +198 -0
  154. mojo/apps/metrics/rest/__init__.py +3 -0
  155. mojo/apps/metrics/rest/categories.py +266 -0
  156. mojo/apps/metrics/rest/helpers.py +48 -0
  157. mojo/apps/metrics/rest/permissions.py +99 -0
  158. mojo/apps/metrics/rest/values.py +277 -0
  159. mojo/apps/metrics/utils.py +19 -2
  160. mojo/decorators/auth.py +6 -1
  161. mojo/decorators/http.py +47 -3
  162. mojo/helpers/aws/__init__.py +45 -0
  163. mojo/helpers/aws/ec2.py +804 -0
  164. mojo/helpers/aws/iam.py +748 -0
  165. mojo/helpers/aws/inbound_email.py +309 -0
  166. mojo/helpers/aws/kms.py +413 -0
  167. mojo/helpers/aws/s3.py +451 -11
  168. mojo/helpers/aws/ses.py +483 -0
  169. mojo/helpers/aws/ses_domain.py +959 -0
  170. mojo/helpers/aws/sns.py +461 -0
  171. mojo/helpers/crypto/__init__.py +1 -1
  172. mojo/helpers/crypto/utils.py +15 -0
  173. mojo/helpers/dates.py +18 -0
  174. mojo/helpers/location/__init__.py +2 -0
  175. mojo/helpers/location/countries.py +262 -0
  176. mojo/helpers/location/geolocation.py +196 -0
  177. mojo/helpers/logit.py +37 -0
  178. mojo/helpers/redis/__init__.py +2 -0
  179. mojo/helpers/redis/adapter.py +606 -0
  180. mojo/helpers/redis/client.py +48 -0
  181. mojo/helpers/redis/pool.py +225 -0
  182. mojo/helpers/request.py +8 -0
  183. mojo/helpers/response.py +14 -2
  184. mojo/helpers/settings/__init__.py +2 -0
  185. mojo/helpers/{settings.py → settings/helper.py} +1 -37
  186. mojo/helpers/settings/parser.py +132 -0
  187. mojo/middleware/auth.py +1 -1
  188. mojo/middleware/cors.py +40 -0
  189. mojo/middleware/logging.py +131 -12
  190. mojo/middleware/mojo.py +10 -0
  191. mojo/models/rest.py +494 -65
  192. mojo/models/secrets.py +98 -3
  193. mojo/serializers/__init__.py +106 -0
  194. mojo/serializers/core/__init__.py +90 -0
  195. mojo/serializers/core/cache/__init__.py +121 -0
  196. mojo/serializers/core/cache/backends.py +518 -0
  197. mojo/serializers/core/cache/base.py +102 -0
  198. mojo/serializers/core/cache/disabled.py +181 -0
  199. mojo/serializers/core/cache/memory.py +287 -0
  200. mojo/serializers/core/cache/redis.py +533 -0
  201. mojo/serializers/core/cache/utils.py +454 -0
  202. mojo/serializers/core/manager.py +550 -0
  203. mojo/serializers/core/serializer.py +475 -0
  204. mojo/serializers/examples/settings.py +322 -0
  205. mojo/serializers/formats/csv.py +393 -0
  206. mojo/serializers/formats/localizers.py +509 -0
  207. mojo/serializers/{models.py → simple.py} +38 -15
  208. mojo/serializers/suggested_improvements.md +388 -0
  209. testit/client.py +1 -1
  210. testit/helpers.py +35 -4
  211. testit/runner.py +23 -6
  212. django_nativemojo-0.1.10.dist-info/METADATA +0 -96
  213. django_nativemojo-0.1.10.dist-info/RECORD +0 -194
  214. mojo/apps/metrics/rest/db.py +0 -0
  215. mojo/apps/notify/README.md +0 -91
  216. mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
  217. mojo/apps/notify/admin.py +0 -52
  218. mojo/apps/notify/handlers/example_handlers.py +0 -516
  219. mojo/apps/notify/handlers/ses/__init__.py +0 -25
  220. mojo/apps/notify/handlers/ses/bounce.py +0 -0
  221. mojo/apps/notify/handlers/ses/complaint.py +0 -25
  222. mojo/apps/notify/handlers/ses/message.py +0 -86
  223. mojo/apps/notify/management/commands/__init__.py +0 -1
  224. mojo/apps/notify/management/commands/process_notifications.py +0 -370
  225. mojo/apps/notify/mod +0 -0
  226. mojo/apps/notify/models/__init__.py +0 -12
  227. mojo/apps/notify/models/account.py +0 -128
  228. mojo/apps/notify/models/attachment.py +0 -24
  229. mojo/apps/notify/models/bounce.py +0 -68
  230. mojo/apps/notify/models/complaint.py +0 -40
  231. mojo/apps/notify/models/inbox.py +0 -113
  232. mojo/apps/notify/models/inbox_message.py +0 -173
  233. mojo/apps/notify/models/outbox.py +0 -129
  234. mojo/apps/notify/models/outbox_message.py +0 -288
  235. mojo/apps/notify/models/template.py +0 -30
  236. mojo/apps/notify/providers/aws.py +0 -73
  237. mojo/apps/notify/rest/ses.py +0 -0
  238. mojo/apps/notify/utils/__init__.py +0 -2
  239. mojo/apps/notify/utils/notifications.py +0 -404
  240. mojo/apps/notify/utils/parsing.py +0 -202
  241. mojo/apps/notify/utils/render.py +0 -144
  242. mojo/apps/tasks/README.md +0 -118
  243. mojo/apps/tasks/__init__.py +0 -11
  244. mojo/apps/tasks/manager.py +0 -489
  245. mojo/apps/tasks/rest/__init__.py +0 -2
  246. mojo/apps/tasks/rest/hooks.py +0 -0
  247. mojo/apps/tasks/rest/tasks.py +0 -62
  248. mojo/apps/tasks/runner.py +0 -174
  249. mojo/apps/tasks/tq_handlers.py +0 -14
  250. mojo/helpers/aws/setup_email.py +0 -0
  251. mojo/helpers/redis.py +0 -10
  252. mojo/models/meta.py +0 -262
  253. mojo/ws4redis/README.md +0 -174
  254. mojo/ws4redis/__init__.py +0 -2
  255. mojo/ws4redis/client.py +0 -283
  256. mojo/ws4redis/connection.py +0 -327
  257. mojo/ws4redis/exceptions.py +0 -32
  258. mojo/ws4redis/redis.py +0 -183
  259. mojo/ws4redis/servers/base.py +0 -86
  260. mojo/ws4redis/servers/django.py +0 -171
  261. mojo/ws4redis/servers/uwsgi.py +0 -63
  262. mojo/ws4redis/settings.py +0 -45
  263. mojo/ws4redis/utf8validator.py +0 -128
  264. mojo/ws4redis/websocket.py +0 -403
  265. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/LICENSE +0 -0
  266. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
  267. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/WHEEL +0 -0
  268. /mojo/apps/{notify → aws}/__init__.py +0 -0
  269. /mojo/apps/{notify/handlers → aws/migrations}/__init__.py +0 -0
  270. /mojo/apps/{notify/management → docit/markdown_plugins}/__init__.py +0 -0
  271. /mojo/apps/{notify/providers → docit/migrations}/__init__.py +0 -0
  272. /mojo/apps/{notify/rest → fileman/migrations}/__init__.py +0 -0
  273. /mojo/{ws4redis/servers → apps/jobs/examples}/__init__.py +0 -0
  274. /mojo/apps/{fileman/models/render.py → jobs/migrations/__init__.py} +0 -0
  275. /mojo/{serializers → rest}/openapi.py +0 -0
  276. /mojo/{apps/fileman/rest/__init__ → serializers/formats/__init__.py} +0 -0
@@ -0,0 +1,571 @@
1
+ """
2
+ Scheduler daemon for moving due jobs from ZSET to List queues (Plan B).
3
+
4
+ Runs as a single active instance using Redis leadership lock.
5
+ Continuously monitors scheduled jobs and enqueues them when due.
6
+ """
7
+ import os
8
+ import sys
9
+ import signal
10
+ import time
11
+ import json
12
+ import uuid
13
+ import random
14
+ import threading
15
+ from datetime import datetime, timedelta
16
+ from typing import List, Optional, Set, Dict
17
+
18
+ from django.utils import timezone
19
+ from django.db import close_old_connections
20
+ from mojo.helpers.settings import settings
21
+
22
+ from mojo.helpers import logit
23
+ from .daemon import DaemonRunner
24
+ from .keys import JobKeys
25
+ from .adapters import get_adapter
26
+ from .models import Job, JobEvent
27
+
28
+ # Module-level settings (readability)
29
+ JOBS_CHANNELS = settings.get('JOBS_CHANNELS', ['default'])
30
+ JOBS_SCHEDULER_LOCK_TTL_MS = settings.get('JOBS_SCHEDULER_LOCK_TTL_MS', 5000)
31
+ JOBS_STREAM_MAXLEN = settings.get('JOBS_STREAM_MAXLEN', 100000)
32
+
33
+ logger = logit.get_logger("scheduler", "scheduler.log")
34
+
35
+ class Scheduler:
36
+ """
37
+ Scheduler daemon that moves due jobs from ZSET to Streams.
38
+
39
+ Uses Redis lock for single-leader pattern to ensure only one
40
+ scheduler is active across the cluster at any time.
41
+ """
42
+
43
+ def __init__(self, channels: Optional[List[str]] = None,
44
+ scheduler_id: Optional[str] = None):
45
+ """
46
+ Initialize the scheduler.
47
+
48
+ Args:
49
+ channels: List of channels to schedule for (default: all configured)
50
+ scheduler_id: Unique scheduler identifier (auto-generated if not provided)
51
+ """
52
+ self.channels = channels or self._get_all_channels()
53
+ self.scheduler_id = scheduler_id or self._generate_scheduler_id()
54
+ self.redis = get_adapter()
55
+ self.keys = JobKeys()
56
+
57
+ # Lock configuration
58
+ self.lock_key = self.keys.scheduler_lock()
59
+ self.lock_ttl_ms = JOBS_SCHEDULER_LOCK_TTL_MS
60
+ self.lock_renew_interval = self.lock_ttl_ms / 1000 / 2 # Renew at half TTL
61
+ self.lock_value = uuid.uuid4().hex # Unique value for this scheduler
62
+
63
+ # Control flags
64
+ self.running = False
65
+ self.stop_event = threading.Event()
66
+ self.has_lock = False
67
+
68
+ # Stats
69
+ self.jobs_scheduled = 0
70
+ self.jobs_expired = 0
71
+ self.start_time = None
72
+
73
+ # Sleep configuration (with jitter)
74
+ self.base_sleep_ms = 250
75
+ self.max_sleep_ms = 500
76
+
77
+ logger.info(f"Scheduler initialized: id={self.scheduler_id}, "
78
+ f"channels={self.channels}")
79
+
80
+ def _get_all_channels(self) -> List[str]:
81
+ """Get all configured channels from settings or discover from Redis."""
82
+ # Try settings first
83
+ configured = JOBS_CHANNELS
84
+ if configured:
85
+ return configured
86
+
87
+ # Default channels
88
+ return ['default']
89
+
90
+ def _generate_scheduler_id(self) -> str:
91
+ """Generate a consistent scheduler ID based on hostname."""
92
+ import socket
93
+ hostname = socket.gethostname()
94
+ # Clean hostname for use in ID (remove dots, make lowercase)
95
+ clean_hostname = hostname.lower().replace('.', '-').replace('_', '-')
96
+
97
+ return f"{clean_hostname}-scheduler"
98
+
99
+ def start(self):
100
+ """
101
+ Start the scheduler daemon.
102
+
103
+ Acquires leadership lock and begins processing scheduled jobs.
104
+ """
105
+ if self.running:
106
+ logger.warn("Scheduler already running")
107
+ return
108
+
109
+ logger.info(f"Starting Scheduler {self.scheduler_id}")
110
+ self.running = True
111
+ self.start_time = timezone.now()
112
+ self.stop_event.clear()
113
+
114
+ # Register signal handlers
115
+ self._setup_signal_handlers()
116
+
117
+ # Main loop with lock management
118
+ try:
119
+ self._main_loop_with_lock()
120
+ except KeyboardInterrupt:
121
+ logger.info("Scheduler interrupted by user")
122
+ except Exception as e:
123
+ logger.error(f"Scheduler crashed: {e}")
124
+ raise
125
+ finally:
126
+ self.stop()
127
+
128
+ def stop(self):
129
+ """Stop the scheduler gracefully."""
130
+ if not self.running:
131
+ return
132
+
133
+ logger.info(f"Stopping Scheduler {self.scheduler_id}...")
134
+ self.running = False
135
+ self.stop_event.set()
136
+
137
+ # Release lock if held
138
+ if self.has_lock:
139
+ self._release_lock()
140
+
141
+ logger.info(f"Scheduler {self.scheduler_id} stopped. "
142
+ f"Scheduled: {self.jobs_scheduled}, Expired: {self.jobs_expired}")
143
+
144
+ def _setup_signal_handlers(self):
145
+ """Register signal handlers for graceful shutdown."""
146
+ def handle_signal(signum, frame):
147
+ logger.info(f"Scheduler received signal {signum}, shutting down")
148
+ self.stop()
149
+ sys.exit(0)
150
+
151
+ signal.signal(signal.SIGTERM, handle_signal)
152
+ signal.signal(signal.SIGINT, handle_signal)
153
+
154
+ def _acquire_lock(self) -> bool:
155
+ """
156
+ Try to acquire the scheduler lock.
157
+
158
+ Returns:
159
+ True if lock acquired, False otherwise
160
+ """
161
+ try:
162
+ # SET key value NX PX milliseconds
163
+ result = self.redis.set(
164
+ self.lock_key,
165
+ self.lock_value,
166
+ nx=True, # Only set if doesn't exist
167
+ px=self.lock_ttl_ms # Expire after milliseconds
168
+ )
169
+
170
+ if result:
171
+ self.has_lock = True
172
+ logger.info(f"Scheduler {self.scheduler_id} acquired lock")
173
+
174
+ # Emit metric
175
+ try:
176
+ from mojo.metrics.redis_metrics import record_metrics
177
+ record_metrics('jobs.scheduler.leader', timezone.now(), 1,
178
+ category='jobs')
179
+ except Exception:
180
+ pass
181
+
182
+ return True
183
+
184
+ return False
185
+
186
+ except Exception as e:
187
+ logger.error(f"Failed to acquire scheduler lock: {e}")
188
+ return False
189
+
190
+ def _renew_lock(self) -> bool:
191
+ """
192
+ Renew the scheduler lock if we still hold it.
193
+
194
+ Returns:
195
+ True if renewed, False if lost
196
+ """
197
+ if not self.has_lock:
198
+ return False
199
+
200
+ try:
201
+ # Check if we still own the lock
202
+ current_value = self.redis.get(self.lock_key)
203
+
204
+ if current_value and current_value == self.lock_value:
205
+ # We still own it, renew TTL
206
+ self.redis.pexpire(self.lock_key, self.lock_ttl_ms)
207
+ return True
208
+ else:
209
+ # Lock stolen or expired
210
+ logger.warn(f"Scheduler {self.scheduler_id} lost lock")
211
+ self.has_lock = False
212
+ return False
213
+
214
+ except Exception as e:
215
+ logger.error(f"Failed to renew scheduler lock: {e}")
216
+ self.has_lock = False
217
+ return False
218
+
219
+ def _release_lock(self):
220
+ """Release the scheduler lock if we hold it."""
221
+ if not self.has_lock:
222
+ return
223
+
224
+ try:
225
+ # Only delete if we own it
226
+ current_value = self.redis.get(self.lock_key)
227
+
228
+ if current_value and current_value == self.lock_value:
229
+ self.redis.delete(self.lock_key)
230
+ logger.info(f"Scheduler {self.scheduler_id} released lock")
231
+
232
+ self.has_lock = False
233
+
234
+ except Exception as e:
235
+ logger.error(f"Failed to release scheduler lock: {e}")
236
+
237
+ def _main_loop_with_lock(self):
238
+ """Main loop with lock acquisition and renewal."""
239
+ last_renew = time.time()
240
+
241
+ while self.running and not self.stop_event.is_set():
242
+ try:
243
+ # Try to acquire lock if we don't have it
244
+ if not self.has_lock:
245
+ if not self._acquire_lock():
246
+ # Failed to acquire, sleep and retry
247
+ time.sleep(2)
248
+ continue
249
+
250
+ # Renew lock if needed
251
+ now = time.time()
252
+ if now - last_renew >= self.lock_renew_interval:
253
+ if not self._renew_lock():
254
+ # Lost lock, go back to acquisition
255
+ continue
256
+ last_renew = now
257
+
258
+ # Process scheduled jobs
259
+ self._process_scheduled_jobs()
260
+
261
+ # Sleep with jitter
262
+ sleep_ms = random.randint(self.base_sleep_ms, self.max_sleep_ms)
263
+ time.sleep(sleep_ms / 1000.0)
264
+
265
+ except Exception as e:
266
+ logger.error(f"Error in scheduler main loop: {e}")
267
+ time.sleep(1)
268
+
269
+ def _process_scheduled_jobs(self):
270
+ """Process scheduled jobs for all channels."""
271
+ now = timezone.now()
272
+ now_ms = now.timestamp() * 1000
273
+
274
+ # Close old DB connections at start
275
+ close_old_connections()
276
+
277
+ for channel in self.channels:
278
+ try:
279
+ self._process_channel(channel, now, now_ms)
280
+ except Exception as e:
281
+ logger.error(f"Failed to process channel {channel}: {e}")
282
+
283
+ def _process_channel(self, channel: str, now: datetime, now_ms: float):
284
+ """
285
+ Process scheduled jobs for a single channel.
286
+
287
+ Args:
288
+ channel: Channel name
289
+ now: Current datetime
290
+ now_ms: Current time in milliseconds
291
+ """
292
+ # Skip channel if paused
293
+ try:
294
+ if self.redis.get(self.keys.channel_pause(channel)):
295
+ return
296
+ except Exception:
297
+ pass
298
+ # Process non-broadcast delayed jobs (Plan B: enqueue to List queue)
299
+ sched_key = self.keys.sched(channel)
300
+ while True:
301
+ results = self.redis.zpopmin(sched_key, count=10)
302
+ if not results:
303
+ break
304
+ not_due: Dict[str, float] = {}
305
+ for job_id, score in results:
306
+ if score > now_ms:
307
+ # Collect not-due items to reinsert after the loop
308
+ not_due[job_id] = score
309
+ else:
310
+ queue_key = self.keys.queue(channel)
311
+ # Enqueue to List queue
312
+ try:
313
+ self.redis.rpush(queue_key, job_id)
314
+ except Exception as e:
315
+ logger.error(f"Failed to enqueue job {job_id} to queue {queue_key}: {e}")
316
+ # If enqueue fails, reinsert back to sched to avoid loss
317
+ not_due[job_id] = score
318
+ continue
319
+ # Record DB event
320
+ try:
321
+ job = Job.objects.get(id=job_id)
322
+ scheduled_at_dt = datetime.fromtimestamp(score / 1000.0)
323
+ if timezone.is_naive(scheduled_at_dt):
324
+ scheduled_at_dt = timezone.make_aware(scheduled_at_dt)
325
+ JobEvent.objects.create(
326
+ job=job,
327
+ channel=channel,
328
+ event='queued',
329
+ details={
330
+ 'scheduler_id': self.scheduler_id,
331
+ 'queue': queue_key,
332
+ 'scheduled_at': scheduled_at_dt.isoformat()
333
+ }
334
+ )
335
+ except Exception as e:
336
+ logger.warn(f"Failed to record queued event for {job_id}: {e}")
337
+ self.jobs_scheduled += 1
338
+ # Re-add all not-due jobs and break (remaining entries are ordered)
339
+ if not_due:
340
+ self.redis.zadd(sched_key, not_due)
341
+ break
342
+
343
+ # Process broadcast delayed jobs (Plan B: if broadcast retained, enqueue to same queue or a special one)
344
+ sched_b_key = self.keys.sched_broadcast(channel)
345
+ while True:
346
+ results = self.redis.zpopmin(sched_b_key, count=10)
347
+ if not results:
348
+ break
349
+ not_due_b: Dict[str, float] = {}
350
+ for job_id, score in results:
351
+ if score > now_ms:
352
+ not_due_b[job_id] = score
353
+ else:
354
+ # For simplicity, enqueue broadcast to the same queue; adjust if broadcast logic changes
355
+ queue_key = self.keys.queue(channel)
356
+ try:
357
+ self.redis.rpush(queue_key, job_id)
358
+ except Exception as e:
359
+ logger.error(f"Failed to enqueue broadcast job {job_id} to queue {queue_key}: {e}")
360
+ not_due_b[job_id] = score
361
+ continue
362
+ try:
363
+ job = Job.objects.get(id=job_id)
364
+ scheduled_at_dt = datetime.fromtimestamp(score / 1000.0)
365
+ if timezone.is_naive(scheduled_at_dt):
366
+ scheduled_at_dt = timezone.make_aware(scheduled_at_dt)
367
+ JobEvent.objects.create(
368
+ job=job,
369
+ channel=channel,
370
+ event='queued',
371
+ details={
372
+ 'scheduler_id': self.scheduler_id,
373
+ 'queue': queue_key,
374
+ 'scheduled_at': scheduled_at_dt.isoformat(),
375
+ 'broadcast': True
376
+ }
377
+ )
378
+ except Exception as e:
379
+ logger.warn(f"Failed to record queued event for broadcast {job_id}: {e}")
380
+ self.jobs_scheduled += 1
381
+ if not_due_b:
382
+ self.redis.zadd(sched_b_key, not_due_b)
383
+ break
384
+
385
+ def _enqueue_job(self, job_id: str, channel: str, now: datetime, stream_key: str, scheduled_at_ms: float):
386
+ """
387
+ Legacy helper retained for compatibility. Not used in Plan B path.
388
+ """
389
+ try:
390
+ queue_key = self.keys.queue(channel)
391
+ self.redis.rpush(queue_key, job_id)
392
+ try:
393
+ job = Job.objects.get(id=job_id)
394
+ scheduled_at_dt = datetime.fromtimestamp(scheduled_at_ms / 1000.0)
395
+ if timezone.is_naive(scheduled_at_dt):
396
+ scheduled_at_dt = timezone.make_aware(scheduled_at_dt)
397
+ JobEvent.objects.create(
398
+ job=job,
399
+ channel=channel,
400
+ event='queued',
401
+ details={
402
+ 'scheduler_id': self.scheduler_id,
403
+ 'queue': queue_key,
404
+ 'scheduled_at': scheduled_at_dt.isoformat()
405
+ }
406
+ )
407
+ except Exception as e:
408
+ logger.warn(f"Failed to record queued event for {job_id}: {e}")
409
+ self.jobs_scheduled += 1
410
+ logger.debug(f"Enqueued job {job_id} to {queue_key}")
411
+ except Exception as e:
412
+ logger.error(f"Failed to enqueue job {job_id}: {e}")
413
+
414
+ def _load_job(self, job_id: str) -> Optional[dict]:
415
+ """Load job data from Redis or database."""
416
+ # DB-only (KISS): skip Redis per-job hash
417
+ # Fall back to database
418
+ try:
419
+ job = Job.objects.get(id=job_id)
420
+ return {
421
+ 'status': job.status,
422
+ 'channel': job.channel,
423
+ 'func': job.func,
424
+ 'expires_at': job.expires_at.isoformat() if job.expires_at else '',
425
+ 'broadcast': '1' if job.broadcast else '0'
426
+ }
427
+ except Job.DoesNotExist:
428
+ return None
429
+
430
+ def _is_expired(self, job_data: dict, now: datetime) -> bool:
431
+ """Check if a job has expired."""
432
+ expires_at = job_data.get('expires_at', '')
433
+ if not expires_at:
434
+ return False
435
+
436
+ try:
437
+ expiry = datetime.fromisoformat(expires_at)
438
+ if timezone.is_naive(expiry):
439
+ expiry = timezone.make_aware(expiry)
440
+ return now > expiry
441
+ except Exception:
442
+ return False
443
+
444
+ def _mark_expired(self, job_id: str, channel: str):
445
+ """Mark a job as expired."""
446
+ try:
447
+ # Redis per-job hash removed (KISS): DB is source of truth
448
+
449
+ # Update database
450
+ job = Job.objects.get(id=job_id)
451
+ job.status = 'expired'
452
+ job.finished_at = timezone.now()
453
+ job.save(update_fields=['status', 'finished_at', 'modified'])
454
+
455
+ # Record event
456
+ JobEvent.objects.create(
457
+ job=job,
458
+ channel=channel,
459
+ event='expired',
460
+ details={'scheduler_id': self.scheduler_id}
461
+ )
462
+
463
+ logger.info(f"Job {job_id} expired at scheduler")
464
+
465
+ # Emit metric
466
+ try:
467
+ from mojo.metrics.redis_metrics import record_metrics
468
+ record_metrics('jobs.expired', timezone.now(), 1, category='jobs')
469
+ except Exception:
470
+ pass
471
+
472
+ except Exception as e:
473
+ logger.error(f"Failed to mark job {job_id} as expired: {e}")
474
+
475
+
476
+ def main():
477
+ """
478
+ Main entry point for running Scheduler as a daemon.
479
+
480
+ This can be called directly or via Django management command.
481
+ """
482
+ import argparse
483
+
484
+ parser = argparse.ArgumentParser(description='Django-MOJO Job Scheduler')
485
+ parser.add_argument(
486
+ '--channels',
487
+ type=str,
488
+ default=None,
489
+ help='Comma-separated list of channels to schedule (default: all)'
490
+ )
491
+ parser.add_argument(
492
+ '--scheduler-id',
493
+ type=str,
494
+ default=None,
495
+ help='Explicit scheduler ID (auto-generated if not provided)'
496
+ )
497
+ parser.add_argument(
498
+ '--daemon',
499
+ action='store_true',
500
+ help='Run as background daemon'
501
+ )
502
+ parser.add_argument(
503
+ '--pidfile',
504
+ type=str,
505
+ default=None,
506
+ help='PID file path (auto-generated if daemon mode and not specified)'
507
+ )
508
+ parser.add_argument(
509
+ '--logfile',
510
+ type=str,
511
+ default=None,
512
+ help='Log file path for daemon mode'
513
+ )
514
+ parser.add_argument(
515
+ '--action',
516
+ type=str,
517
+ choices=['start', 'stop', 'restart', 'status'],
518
+ default='start',
519
+ help='Daemon control action (only with --daemon)'
520
+ )
521
+
522
+ args = parser.parse_args()
523
+
524
+ # Parse channels if provided
525
+ channels = None
526
+ if args.channels:
527
+ channels = [c.strip() for c in args.channels.split(',')]
528
+
529
+ # Create scheduler
530
+ scheduler = Scheduler(channels=channels, scheduler_id=args.scheduler_id)
531
+
532
+ # Auto-generate pidfile if daemon mode and not specified
533
+ if args.daemon and not args.pidfile:
534
+ scheduler_id = scheduler.scheduler_id
535
+ args.pidfile = f"/tmp/job-scheduler-{scheduler_id}.pid"
536
+
537
+ # Setup daemon runner
538
+ runner = DaemonRunner(
539
+ name="Scheduler",
540
+ run_func=scheduler.start,
541
+ stop_func=scheduler.stop,
542
+ pidfile=args.pidfile,
543
+ logfile=args.logfile,
544
+ daemon=args.daemon
545
+ )
546
+
547
+ # Handle daemon actions
548
+ if args.daemon and args.action != 'start':
549
+ if args.action == 'stop':
550
+ sys.exit(0 if runner.stop() else 1)
551
+ elif args.action == 'restart':
552
+ runner.restart()
553
+ sys.exit(0)
554
+ elif args.action == 'status':
555
+ if runner.status():
556
+ print(f"Scheduler is running (PID file: {args.pidfile})")
557
+ sys.exit(0)
558
+ else:
559
+ print(f"Scheduler is not running")
560
+ sys.exit(1)
561
+ else:
562
+ # Start the scheduler (foreground or background)
563
+ try:
564
+ runner.start()
565
+ except Exception as e:
566
+ logit.error(f"Scheduler failed: {e}")
567
+ sys.exit(1)
568
+
569
+
570
+ if __name__ == '__main__':
571
+ main()
@@ -0,0 +1,6 @@
1
+ """
2
+ Jobs services for business logic.
3
+ """
4
+ from .job_actions import JobActionsService
5
+
6
+ __all__ = ['JobActionsService']