django-nativemojo 0.1.15__py3-none-any.whl → 0.1.17__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.
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/METADATA +3 -2
- django_nativemojo-0.1.17.dist-info/RECORD +302 -0
- mojo/__init__.py +1 -1
- mojo/apps/account/management/commands/serializer_admin.py +121 -1
- mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
- mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
- mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
- mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
- mojo/apps/account/migrations/0010_group_avatar.py +20 -0
- mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
- mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
- mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
- mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
- mojo/apps/account/models/__init__.py +2 -0
- mojo/apps/account/models/device.py +279 -0
- mojo/apps/account/models/group.py +294 -8
- mojo/apps/account/models/member.py +14 -1
- mojo/apps/account/models/push/__init__.py +4 -0
- mojo/apps/account/models/push/config.py +112 -0
- mojo/apps/account/models/push/delivery.py +93 -0
- mojo/apps/account/models/push/device.py +66 -0
- mojo/apps/account/models/push/template.py +99 -0
- mojo/apps/account/models/user.py +190 -17
- mojo/apps/account/rest/__init__.py +2 -0
- mojo/apps/account/rest/device.py +39 -0
- mojo/apps/account/rest/group.py +8 -0
- mojo/apps/account/rest/push.py +187 -0
- mojo/apps/account/rest/user.py +95 -5
- mojo/apps/account/services/__init__.py +1 -0
- mojo/apps/account/services/push.py +363 -0
- mojo/apps/aws/migrations/0001_initial.py +206 -0
- mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
- mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
- mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
- mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
- mojo/apps/aws/models/__init__.py +19 -0
- mojo/apps/aws/models/email_attachment.py +99 -0
- mojo/apps/aws/models/email_domain.py +218 -0
- mojo/apps/aws/models/email_template.py +132 -0
- mojo/apps/aws/models/incoming_email.py +197 -0
- mojo/apps/aws/models/mailbox.py +288 -0
- mojo/apps/aws/models/sent_message.py +175 -0
- mojo/apps/aws/rest/__init__.py +6 -0
- mojo/apps/aws/rest/email.py +33 -0
- mojo/apps/aws/rest/email_ops.py +183 -0
- mojo/apps/aws/rest/messages.py +32 -0
- mojo/apps/aws/rest/send.py +101 -0
- mojo/apps/aws/rest/sns.py +403 -0
- mojo/apps/aws/rest/templates.py +19 -0
- mojo/apps/aws/services/__init__.py +32 -0
- mojo/apps/aws/services/email.py +390 -0
- mojo/apps/aws/services/email_ops.py +548 -0
- mojo/apps/docit/__init__.py +6 -0
- mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
- mojo/apps/docit/markdown_plugins/toc.py +12 -0
- mojo/apps/docit/migrations/0001_initial.py +113 -0
- mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
- mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
- mojo/apps/docit/models/__init__.py +17 -0
- mojo/apps/docit/models/asset.py +231 -0
- mojo/apps/docit/models/book.py +227 -0
- mojo/apps/docit/models/page.py +319 -0
- mojo/apps/docit/models/page_revision.py +203 -0
- mojo/apps/docit/rest/__init__.py +10 -0
- mojo/apps/docit/rest/asset.py +17 -0
- mojo/apps/docit/rest/book.py +22 -0
- mojo/apps/docit/rest/page.py +22 -0
- mojo/apps/docit/rest/page_revision.py +17 -0
- mojo/apps/docit/services/__init__.py +11 -0
- mojo/apps/docit/services/docit.py +315 -0
- mojo/apps/docit/services/markdown.py +44 -0
- mojo/apps/fileman/backends/s3.py +209 -0
- mojo/apps/fileman/models/file.py +45 -9
- mojo/apps/fileman/models/manager.py +269 -3
- mojo/apps/incident/migrations/0007_event_uid.py +18 -0
- mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
- mojo/apps/incident/migrations/0009_incident_status.py +18 -0
- mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
- mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
- mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
- mojo/apps/incident/models/__init__.py +1 -0
- mojo/apps/incident/models/event.py +35 -0
- mojo/apps/incident/models/incident.py +2 -0
- mojo/apps/incident/models/ticket.py +62 -0
- mojo/apps/incident/reporter.py +21 -3
- mojo/apps/incident/rest/__init__.py +1 -0
- mojo/apps/incident/rest/ticket.py +43 -0
- mojo/apps/jobs/__init__.py +489 -0
- mojo/apps/jobs/adapters.py +24 -0
- mojo/apps/jobs/cli.py +616 -0
- mojo/apps/jobs/daemon.py +370 -0
- mojo/apps/jobs/examples/sample_jobs.py +376 -0
- mojo/apps/jobs/examples/webhook_examples.py +203 -0
- mojo/apps/jobs/handlers/__init__.py +5 -0
- mojo/apps/jobs/handlers/webhook.py +317 -0
- mojo/apps/jobs/job_engine.py +734 -0
- mojo/apps/jobs/keys.py +203 -0
- mojo/apps/jobs/local_queue.py +363 -0
- mojo/apps/jobs/management/__init__.py +3 -0
- mojo/apps/jobs/management/commands/__init__.py +3 -0
- mojo/apps/jobs/manager.py +1327 -0
- mojo/apps/jobs/migrations/0001_initial.py +97 -0
- mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
- mojo/apps/jobs/models/__init__.py +6 -0
- mojo/apps/jobs/models/job.py +441 -0
- mojo/apps/jobs/rest/__init__.py +2 -0
- mojo/apps/jobs/rest/control.py +466 -0
- mojo/apps/jobs/rest/jobs.py +421 -0
- mojo/apps/jobs/scheduler.py +571 -0
- mojo/apps/jobs/services/__init__.py +6 -0
- mojo/apps/jobs/services/job_actions.py +465 -0
- mojo/apps/jobs/settings.py +209 -0
- mojo/apps/logit/models/log.py +3 -0
- mojo/apps/metrics/__init__.py +8 -1
- mojo/apps/metrics/redis_metrics.py +198 -0
- mojo/apps/metrics/rest/__init__.py +3 -0
- mojo/apps/metrics/rest/categories.py +266 -0
- mojo/apps/metrics/rest/helpers.py +48 -0
- mojo/apps/metrics/rest/permissions.py +99 -0
- mojo/apps/metrics/rest/values.py +277 -0
- mojo/apps/metrics/utils.py +17 -0
- mojo/decorators/http.py +40 -1
- mojo/helpers/aws/__init__.py +11 -7
- mojo/helpers/aws/inbound_email.py +309 -0
- mojo/helpers/aws/kms.py +413 -0
- mojo/helpers/aws/ses_domain.py +959 -0
- mojo/helpers/crypto/__init__.py +1 -1
- mojo/helpers/crypto/utils.py +15 -0
- mojo/helpers/location/__init__.py +2 -0
- mojo/helpers/location/countries.py +262 -0
- mojo/helpers/location/geolocation.py +196 -0
- mojo/helpers/logit.py +37 -0
- mojo/helpers/redis/__init__.py +2 -0
- mojo/helpers/redis/adapter.py +606 -0
- mojo/helpers/redis/client.py +48 -0
- mojo/helpers/redis/pool.py +225 -0
- mojo/helpers/request.py +8 -0
- mojo/helpers/response.py +8 -0
- mojo/middleware/auth.py +1 -1
- mojo/middleware/cors.py +40 -0
- mojo/middleware/logging.py +131 -12
- mojo/middleware/mojo.py +5 -0
- mojo/models/rest.py +271 -57
- mojo/models/secrets.py +86 -0
- mojo/serializers/__init__.py +16 -10
- mojo/serializers/core/__init__.py +90 -0
- mojo/serializers/core/cache/__init__.py +121 -0
- mojo/serializers/core/cache/backends.py +518 -0
- mojo/serializers/core/cache/base.py +102 -0
- mojo/serializers/core/cache/disabled.py +181 -0
- mojo/serializers/core/cache/memory.py +287 -0
- mojo/serializers/core/cache/redis.py +533 -0
- mojo/serializers/core/cache/utils.py +454 -0
- mojo/serializers/{manager.py → core/manager.py} +53 -4
- mojo/serializers/core/serializer.py +475 -0
- mojo/serializers/{advanced/formats → formats}/csv.py +116 -139
- mojo/serializers/suggested_improvements.md +388 -0
- testit/client.py +1 -1
- testit/helpers.py +14 -0
- testit/runner.py +23 -6
- django_nativemojo-0.1.15.dist-info/RECORD +0 -234
- mojo/apps/notify/README.md +0 -91
- mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
- mojo/apps/notify/admin.py +0 -52
- mojo/apps/notify/handlers/example_handlers.py +0 -516
- mojo/apps/notify/handlers/ses/__init__.py +0 -25
- mojo/apps/notify/handlers/ses/complaint.py +0 -25
- mojo/apps/notify/handlers/ses/message.py +0 -86
- mojo/apps/notify/management/commands/__init__.py +0 -1
- mojo/apps/notify/management/commands/process_notifications.py +0 -370
- mojo/apps/notify/mod +0 -0
- mojo/apps/notify/models/__init__.py +0 -12
- mojo/apps/notify/models/account.py +0 -128
- mojo/apps/notify/models/attachment.py +0 -24
- mojo/apps/notify/models/bounce.py +0 -68
- mojo/apps/notify/models/complaint.py +0 -40
- mojo/apps/notify/models/inbox.py +0 -113
- mojo/apps/notify/models/inbox_message.py +0 -173
- mojo/apps/notify/models/outbox.py +0 -129
- mojo/apps/notify/models/outbox_message.py +0 -288
- mojo/apps/notify/models/template.py +0 -30
- mojo/apps/notify/providers/aws.py +0 -73
- mojo/apps/notify/rest/ses.py +0 -0
- mojo/apps/notify/utils/__init__.py +0 -2
- mojo/apps/notify/utils/notifications.py +0 -404
- mojo/apps/notify/utils/parsing.py +0 -202
- mojo/apps/notify/utils/render.py +0 -144
- mojo/apps/tasks/README.md +0 -118
- mojo/apps/tasks/__init__.py +0 -44
- mojo/apps/tasks/manager.py +0 -644
- mojo/apps/tasks/rest/__init__.py +0 -2
- mojo/apps/tasks/rest/hooks.py +0 -0
- mojo/apps/tasks/rest/tasks.py +0 -76
- mojo/apps/tasks/runner.py +0 -439
- mojo/apps/tasks/task.py +0 -99
- mojo/apps/tasks/tq_handlers.py +0 -132
- mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
- mojo/helpers/redis.py +0 -10
- mojo/models/meta.py +0 -262
- mojo/serializers/advanced/README.md +0 -363
- mojo/serializers/advanced/__init__.py +0 -247
- mojo/serializers/advanced/formats/__init__.py +0 -28
- mojo/serializers/advanced/formats/excel.py +0 -516
- mojo/serializers/advanced/formats/json.py +0 -239
- mojo/serializers/advanced/formats/response.py +0 -485
- mojo/serializers/advanced/serializer.py +0 -568
- mojo/serializers/optimized.py +0 -618
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/LICENSE +0 -0
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/NOTICE +0 -0
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/WHEEL +0 -0
- /mojo/apps/{notify → aws/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/handlers → docit/markdown_plugins}/__init__.py +0 -0
- /mojo/apps/{notify/management → docit/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/providers → jobs/examples}/__init__.py +0 -0
- /mojo/apps/{notify/rest → jobs/migrations}/__init__.py +0 -0
- /mojo/{serializers → rest}/openapi.py +0 -0
- /mojo/serializers/{settings_example.py → examples/settings.py} +0 -0
- /mojo/{apps/notify/handlers/ses/bounce.py → serializers/formats/__init__.py} +0 -0
- /mojo/serializers/{advanced/formats → formats}/localizers.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()
|