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,465 @@
|
|
1
|
+
"""
|
2
|
+
Job Actions Service - Business logic for job operations.
|
3
|
+
|
4
|
+
Handles cancel, retry, status and other job actions separately from the model.
|
5
|
+
"""
|
6
|
+
from typing import Any, Dict, Optional
|
7
|
+
from datetime import timedelta
|
8
|
+
from django.utils import timezone
|
9
|
+
from mojo.helpers import logit
|
10
|
+
|
11
|
+
|
12
|
+
class JobActionsService:
|
13
|
+
"""
|
14
|
+
Service class for job action business logic.
|
15
|
+
|
16
|
+
Keeps models clean by handling complex operations here.
|
17
|
+
"""
|
18
|
+
|
19
|
+
@staticmethod
|
20
|
+
def cancel_job(job) -> Dict[str, Any]:
|
21
|
+
"""
|
22
|
+
Cancel a job.
|
23
|
+
|
24
|
+
Behavior:
|
25
|
+
- If job is terminal: refuse
|
26
|
+
- If job is running:
|
27
|
+
- If runner heartbeat is not alive, force cancel (status='canceled')
|
28
|
+
- If runner alive, set cancel_requested=True (cooperative cancel)
|
29
|
+
- If job is not running (e.g., pending/scheduled/failed/expired): set status='canceled'
|
30
|
+
|
31
|
+
Also attempts to remove from scheduled ZSETs when applicable.
|
32
|
+
|
33
|
+
Args:
|
34
|
+
job: Job model instance
|
35
|
+
|
36
|
+
Returns:
|
37
|
+
dict: Response with status and message
|
38
|
+
"""
|
39
|
+
# Check terminal
|
40
|
+
if job.is_terminal:
|
41
|
+
return {
|
42
|
+
'status': False,
|
43
|
+
'error': f'Cannot cancel job in {job.status} state'
|
44
|
+
}
|
45
|
+
|
46
|
+
now = timezone.now()
|
47
|
+
previous_status = job.status
|
48
|
+
forced = False
|
49
|
+
|
50
|
+
try:
|
51
|
+
# Determine if runner is alive when job is marked running
|
52
|
+
runner_alive = False
|
53
|
+
if job.status == 'running' and job.runner_id:
|
54
|
+
from mojo.apps.jobs.adapters import get_adapter
|
55
|
+
from mojo.apps.jobs.keys import JobKeys
|
56
|
+
redis = get_adapter()
|
57
|
+
keys = JobKeys()
|
58
|
+
hb = redis.get(keys.runner_hb(job.runner_id))
|
59
|
+
runner_alive = bool(hb)
|
60
|
+
|
61
|
+
if job.status == 'running':
|
62
|
+
if runner_alive:
|
63
|
+
# Cooperative cancel for running job
|
64
|
+
job.cancel_requested = True
|
65
|
+
job.save(update_fields=['cancel_requested', 'modified'])
|
66
|
+
else:
|
67
|
+
# Force cancel stale running job
|
68
|
+
job.status = 'canceled'
|
69
|
+
job.finished_at = now
|
70
|
+
job.cancel_requested = True
|
71
|
+
job.runner_id = None
|
72
|
+
job.save(update_fields=['status', 'finished_at', 'cancel_requested', 'runner_id', 'modified'])
|
73
|
+
forced = True
|
74
|
+
else:
|
75
|
+
# Not running: cancel immediately
|
76
|
+
job.status = 'canceled'
|
77
|
+
job.finished_at = now
|
78
|
+
job.cancel_requested = True
|
79
|
+
job.runner_id = None
|
80
|
+
job.save(update_fields=['status', 'finished_at', 'cancel_requested', 'runner_id', 'modified'])
|
81
|
+
|
82
|
+
# Best-effort: remove from scheduled ZSETs if it was scheduled
|
83
|
+
try:
|
84
|
+
from mojo.apps.jobs.adapters import get_adapter
|
85
|
+
from mojo.apps.jobs.keys import JobKeys
|
86
|
+
redis = get_adapter()
|
87
|
+
keys = JobKeys()
|
88
|
+
# Remove from both sched sets; only one will match
|
89
|
+
redis.zadd # touch to appease linters; real calls below
|
90
|
+
redis.zrem = redis.get_client().zrem # ensure we have zrem via client
|
91
|
+
redis.get_client().zrem(keys.sched(job.channel), job.id)
|
92
|
+
redis.get_client().zrem(keys.sched_broadcast(job.channel), job.id)
|
93
|
+
except Exception as e:
|
94
|
+
logit.debug(f"Cancel cleanup (sched zrem) failed for {job.id}: {e}")
|
95
|
+
|
96
|
+
# Record event
|
97
|
+
from mojo.apps.jobs.models import JobEvent
|
98
|
+
JobEvent.objects.create(
|
99
|
+
job=job,
|
100
|
+
channel=job.channel,
|
101
|
+
event='canceled',
|
102
|
+
details={
|
103
|
+
'requested_at': now.isoformat(),
|
104
|
+
'forced': forced,
|
105
|
+
'previous_status': previous_status
|
106
|
+
}
|
107
|
+
)
|
108
|
+
|
109
|
+
logit.info(f"Cancellation {'forced' if forced else 'requested'} for job {job.id} (prev={previous_status})")
|
110
|
+
|
111
|
+
return {
|
112
|
+
'status': True,
|
113
|
+
'message': f"Job {job.id} {'canceled' if job.status == 'canceled' else 'cancellation requested'}",
|
114
|
+
'job_id': job.id,
|
115
|
+
'forced': forced
|
116
|
+
}
|
117
|
+
|
118
|
+
except Exception as e:
|
119
|
+
logit.error(f"Failed to cancel job {job.id}: {e}")
|
120
|
+
return {
|
121
|
+
'status': False,
|
122
|
+
'error': f'Failed to cancel job: {str(e)}'
|
123
|
+
}
|
124
|
+
|
125
|
+
@staticmethod
|
126
|
+
def retry_job(job, delay: Optional[int] = None) -> Dict[str, Any]:
|
127
|
+
"""
|
128
|
+
Retry a failed or canceled job.
|
129
|
+
|
130
|
+
Args:
|
131
|
+
job: Job model instance
|
132
|
+
delay: Optional delay in seconds before retry
|
133
|
+
|
134
|
+
Returns:
|
135
|
+
dict: Response with status and new job ID
|
136
|
+
"""
|
137
|
+
# Check if job can be retried
|
138
|
+
if job.status not in ('failed', 'canceled', 'expired'):
|
139
|
+
return {
|
140
|
+
'status': False,
|
141
|
+
'error': f'Cannot retry job in {job.status} state'
|
142
|
+
}
|
143
|
+
|
144
|
+
# Reset job for retry
|
145
|
+
job.status = 'pending'
|
146
|
+
job.attempt = 0
|
147
|
+
job.last_error = ''
|
148
|
+
job.stack_trace = ''
|
149
|
+
job.cancel_requested = False
|
150
|
+
job.runner_id = None
|
151
|
+
job.started_at = None
|
152
|
+
job.finished_at = None
|
153
|
+
|
154
|
+
# Set run_at if delay specified
|
155
|
+
if delay:
|
156
|
+
job.run_at = timezone.now() + timedelta(seconds=int(delay))
|
157
|
+
else:
|
158
|
+
job.run_at = None
|
159
|
+
|
160
|
+
job.save()
|
161
|
+
|
162
|
+
# Re-publish to Redis
|
163
|
+
try:
|
164
|
+
from mojo.apps.jobs import publish
|
165
|
+
|
166
|
+
# Re-publish the job
|
167
|
+
new_job_id = publish(
|
168
|
+
func=job.func,
|
169
|
+
payload=job.payload,
|
170
|
+
channel=job.channel,
|
171
|
+
run_at=job.run_at,
|
172
|
+
broadcast=job.broadcast,
|
173
|
+
max_retries=job.max_retries,
|
174
|
+
backoff_base=job.backoff_base,
|
175
|
+
backoff_max=job.backoff_max_sec,
|
176
|
+
expires_at=job.expires_at,
|
177
|
+
max_exec_seconds=job.max_exec_seconds
|
178
|
+
)
|
179
|
+
|
180
|
+
# Record event
|
181
|
+
from mojo.apps.jobs.models import JobEvent
|
182
|
+
JobEvent.objects.create(
|
183
|
+
job=job,
|
184
|
+
channel=job.channel,
|
185
|
+
event='retry',
|
186
|
+
details={
|
187
|
+
'retry_requested': True,
|
188
|
+
'new_job_id': new_job_id,
|
189
|
+
'delay': delay
|
190
|
+
}
|
191
|
+
)
|
192
|
+
|
193
|
+
logit.info(f"Job {job.id} retry scheduled as {new_job_id}")
|
194
|
+
|
195
|
+
return {
|
196
|
+
'status': True,
|
197
|
+
'message': f'Job retry scheduled',
|
198
|
+
'original_job_id': job.id,
|
199
|
+
'new_job_id': new_job_id,
|
200
|
+
'delayed': delay is not None
|
201
|
+
}
|
202
|
+
|
203
|
+
except Exception as e:
|
204
|
+
logit.error(f"Failed to retry job {job.id}: {e}")
|
205
|
+
return {
|
206
|
+
'status': False,
|
207
|
+
'error': f'Failed to retry job: {str(e)}'
|
208
|
+
}
|
209
|
+
|
210
|
+
@staticmethod
|
211
|
+
def get_job_status(job) -> Dict[str, Any]:
|
212
|
+
"""
|
213
|
+
Get detailed status of a job.
|
214
|
+
|
215
|
+
Args:
|
216
|
+
job: Job model instance
|
217
|
+
|
218
|
+
Returns:
|
219
|
+
dict: Detailed job status information
|
220
|
+
"""
|
221
|
+
# Build detailed status response
|
222
|
+
status_data = {
|
223
|
+
'id': job.id,
|
224
|
+
'status': job.status,
|
225
|
+
'channel': job.channel,
|
226
|
+
'func': job.func,
|
227
|
+
'created': job.created.isoformat() if job.created else None,
|
228
|
+
'started_at': job.started_at.isoformat() if job.started_at else None,
|
229
|
+
'finished_at': job.finished_at.isoformat() if job.finished_at else None,
|
230
|
+
'attempt': job.attempt,
|
231
|
+
'max_retries': job.max_retries,
|
232
|
+
'last_error': job.last_error,
|
233
|
+
'metadata': job.metadata,
|
234
|
+
'runner_id': job.runner_id,
|
235
|
+
'cancel_requested': job.cancel_requested,
|
236
|
+
'duration_ms': job.duration_ms,
|
237
|
+
'is_terminal': job.is_terminal,
|
238
|
+
'is_retriable': job.is_retriable
|
239
|
+
}
|
240
|
+
|
241
|
+
# Add recent events
|
242
|
+
try:
|
243
|
+
events = job.events.order_by('-at')[:10]
|
244
|
+
status_data['recent_events'] = [
|
245
|
+
{
|
246
|
+
'event': e.event,
|
247
|
+
'at': e.at.isoformat(),
|
248
|
+
'runner_id': e.runner_id,
|
249
|
+
'details': e.details
|
250
|
+
}
|
251
|
+
for e in events
|
252
|
+
]
|
253
|
+
except Exception as e:
|
254
|
+
logit.debug(f"Failed to get events for job {job.id}: {e}")
|
255
|
+
status_data['recent_events'] = []
|
256
|
+
|
257
|
+
# Check position in queue if pending and scheduled
|
258
|
+
if job.status == 'pending' and job.run_at:
|
259
|
+
try:
|
260
|
+
from mojo.apps.jobs.adapters import get_adapter
|
261
|
+
from mojo.apps.jobs.keys import JobKeys
|
262
|
+
|
263
|
+
redis = get_adapter()
|
264
|
+
keys = JobKeys()
|
265
|
+
sched_key = keys.sched(job.channel)
|
266
|
+
|
267
|
+
# Get position in scheduled queue
|
268
|
+
rank = redis.get_client().zrank(sched_key, job.id)
|
269
|
+
if rank is not None:
|
270
|
+
status_data['queue_position'] = rank + 1
|
271
|
+
except Exception as e:
|
272
|
+
logit.debug(f"Failed to get queue position for {job.id}: {e}")
|
273
|
+
|
274
|
+
return {
|
275
|
+
'status': True,
|
276
|
+
'data': status_data
|
277
|
+
}
|
278
|
+
|
279
|
+
@staticmethod
|
280
|
+
def pause_job(job) -> Dict[str, Any]:
|
281
|
+
"""
|
282
|
+
Pause a pending job (remove from queue but keep in DB).
|
283
|
+
|
284
|
+
Args:
|
285
|
+
job: Job model instance
|
286
|
+
|
287
|
+
Returns:
|
288
|
+
dict: Response with status and message
|
289
|
+
"""
|
290
|
+
if job.status != 'pending':
|
291
|
+
return {
|
292
|
+
'status': False,
|
293
|
+
'error': f'Cannot pause job in {job.status} state'
|
294
|
+
}
|
295
|
+
|
296
|
+
# Update status to paused (using canceled state but with metadata)
|
297
|
+
job.status = 'canceled'
|
298
|
+
job.metadata['paused'] = True
|
299
|
+
job.metadata['paused_at'] = timezone.now().isoformat()
|
300
|
+
job.save(update_fields=['status', 'metadata', 'modified'])
|
301
|
+
|
302
|
+
# Remove from Redis queue if present
|
303
|
+
try:
|
304
|
+
from mojo.apps.jobs.adapters import get_adapter
|
305
|
+
from mojo.apps.jobs.keys import JobKeys
|
306
|
+
|
307
|
+
redis = get_adapter()
|
308
|
+
keys = JobKeys()
|
309
|
+
|
310
|
+
# Remove from scheduled queue if scheduled
|
311
|
+
if job.run_at:
|
312
|
+
sched_key = keys.sched(job.channel)
|
313
|
+
redis.get_client().zrem(sched_key, job.id)
|
314
|
+
|
315
|
+
# Remove job hash
|
316
|
+
redis.delete(keys.job(job.id))
|
317
|
+
|
318
|
+
except Exception as e:
|
319
|
+
logit.warn(f"Failed to remove job {job.id} from Redis: {e}")
|
320
|
+
|
321
|
+
# Record event
|
322
|
+
from mojo.apps.jobs.models import JobEvent
|
323
|
+
JobEvent.objects.create(
|
324
|
+
job=job,
|
325
|
+
channel=job.channel,
|
326
|
+
event='canceled',
|
327
|
+
details={'paused': True}
|
328
|
+
)
|
329
|
+
|
330
|
+
logit.info(f"Job {job.id} paused")
|
331
|
+
|
332
|
+
return {
|
333
|
+
'status': True,
|
334
|
+
'message': f'Job {job.id} paused',
|
335
|
+
'job_id': job.id
|
336
|
+
}
|
337
|
+
|
338
|
+
@staticmethod
|
339
|
+
def resume_job(job) -> Dict[str, Any]:
|
340
|
+
"""
|
341
|
+
Resume a paused job.
|
342
|
+
|
343
|
+
Args:
|
344
|
+
job: Job model instance
|
345
|
+
|
346
|
+
Returns:
|
347
|
+
dict: Response with status and message
|
348
|
+
"""
|
349
|
+
# Check if job is actually paused
|
350
|
+
if job.status != 'canceled' or not job.metadata.get('paused'):
|
351
|
+
return {
|
352
|
+
'status': False,
|
353
|
+
'error': 'Job is not paused'
|
354
|
+
}
|
355
|
+
|
356
|
+
# Reset to pending and clear pause metadata
|
357
|
+
job.status = 'pending'
|
358
|
+
job.metadata.pop('paused', None)
|
359
|
+
job.metadata.pop('paused_at', None)
|
360
|
+
job.metadata['resumed_at'] = timezone.now().isoformat()
|
361
|
+
job.save(update_fields=['status', 'metadata', 'modified'])
|
362
|
+
|
363
|
+
# Re-publish to Redis
|
364
|
+
try:
|
365
|
+
from mojo.apps.jobs import publish
|
366
|
+
|
367
|
+
new_job_id = publish(
|
368
|
+
func=job.func,
|
369
|
+
payload=job.payload,
|
370
|
+
channel=job.channel,
|
371
|
+
run_at=job.run_at,
|
372
|
+
broadcast=job.broadcast,
|
373
|
+
max_retries=job.max_retries,
|
374
|
+
backoff_base=job.backoff_base,
|
375
|
+
backoff_max=job.backoff_max_sec,
|
376
|
+
expires_at=job.expires_at,
|
377
|
+
max_exec_seconds=job.max_exec_seconds
|
378
|
+
)
|
379
|
+
|
380
|
+
# Record event
|
381
|
+
from mojo.apps.jobs.models import JobEvent
|
382
|
+
JobEvent.objects.create(
|
383
|
+
job=job,
|
384
|
+
channel=job.channel,
|
385
|
+
event='queued',
|
386
|
+
details={'resumed': True, 'new_job_id': new_job_id}
|
387
|
+
)
|
388
|
+
|
389
|
+
logit.info(f"Job {job.id} resumed as {new_job_id}")
|
390
|
+
|
391
|
+
return {
|
392
|
+
'status': True,
|
393
|
+
'message': f'Job resumed',
|
394
|
+
'original_job_id': job.id,
|
395
|
+
'new_job_id': new_job_id
|
396
|
+
}
|
397
|
+
|
398
|
+
except Exception as e:
|
399
|
+
logit.error(f"Failed to resume job {job.id}: {e}")
|
400
|
+
return {
|
401
|
+
'status': False,
|
402
|
+
'error': f'Failed to resume job: {str(e)}'
|
403
|
+
}
|
404
|
+
|
405
|
+
@staticmethod
|
406
|
+
def publish_job_from_template(job, overrides: Dict[str, Any]) -> Dict[str, Any]:
|
407
|
+
"""
|
408
|
+
Publish a new job using an existing job as a template.
|
409
|
+
|
410
|
+
Args:
|
411
|
+
job: Job model instance to use as template
|
412
|
+
overrides: Dict with optional overrides for the new job
|
413
|
+
|
414
|
+
Returns:
|
415
|
+
dict: Response with new job ID
|
416
|
+
"""
|
417
|
+
try:
|
418
|
+
from mojo.apps.jobs import publish
|
419
|
+
|
420
|
+
# Build parameters from template job
|
421
|
+
params = {
|
422
|
+
'func': overrides.get('func', job.func),
|
423
|
+
'payload': overrides.get('payload', job.payload),
|
424
|
+
'channel': overrides.get('channel', job.channel),
|
425
|
+
'broadcast': overrides.get('broadcast', job.broadcast),
|
426
|
+
'max_retries': overrides.get('max_retries', job.max_retries),
|
427
|
+
'backoff_base': overrides.get('backoff_base', job.backoff_base),
|
428
|
+
'backoff_max': overrides.get('backoff_max', job.backoff_max_sec),
|
429
|
+
'max_exec_seconds': overrides.get('max_exec_seconds', job.max_exec_seconds),
|
430
|
+
}
|
431
|
+
|
432
|
+
# Handle scheduling
|
433
|
+
if 'delay' in overrides:
|
434
|
+
params['delay'] = overrides['delay']
|
435
|
+
elif 'run_at' in overrides:
|
436
|
+
params['run_at'] = overrides['run_at']
|
437
|
+
elif job.run_at:
|
438
|
+
params['run_at'] = job.run_at
|
439
|
+
|
440
|
+
# Handle expiration
|
441
|
+
if 'expires_in' in overrides:
|
442
|
+
params['expires_in'] = overrides['expires_in']
|
443
|
+
elif 'expires_at' in overrides:
|
444
|
+
params['expires_at'] = overrides['expires_at']
|
445
|
+
elif job.expires_at:
|
446
|
+
params['expires_at'] = job.expires_at
|
447
|
+
|
448
|
+
# Publish the new job
|
449
|
+
new_job_id = publish(**params)
|
450
|
+
|
451
|
+
logit.info(f"Published new job {new_job_id} from template {job.id}")
|
452
|
+
|
453
|
+
return {
|
454
|
+
'status': True,
|
455
|
+
'message': 'Job published successfully',
|
456
|
+
'job_id': new_job_id,
|
457
|
+
'template_job_id': job.id
|
458
|
+
}
|
459
|
+
|
460
|
+
except Exception as e:
|
461
|
+
logit.error(f"Failed to publish job from template {job.id}: {e}")
|
462
|
+
return {
|
463
|
+
'status': False,
|
464
|
+
'error': f'Failed to publish job: {str(e)}'
|
465
|
+
}
|
@@ -0,0 +1,209 @@
|
|
1
|
+
"""
|
2
|
+
Django-MOJO Jobs System Configuration Settings
|
3
|
+
|
4
|
+
Add these settings to your Django settings.py file to configure the jobs system.
|
5
|
+
"""
|
6
|
+
|
7
|
+
# Redis Configuration
|
8
|
+
JOBS_REDIS_URL = "redis://localhost:6379/0"
|
9
|
+
JOBS_REDIS_PREFIX = "mojo:jobs"
|
10
|
+
|
11
|
+
# Engine Configuration
|
12
|
+
JOBS_ENGINE_MAX_WORKERS = 10 # Thread pool size per engine
|
13
|
+
JOBS_ENGINE_CLAIM_BUFFER = 2 # Claim up to buffer * max_workers jobs
|
14
|
+
JOBS_ENGINE_CLAIM_BATCH = 5 # Max jobs to claim in one request
|
15
|
+
JOBS_ENGINE_READ_TIMEOUT = 100 # Redis XREADGROUP timeout in ms
|
16
|
+
|
17
|
+
# Job Defaults
|
18
|
+
JOBS_DEFAULT_CHANNEL = "default"
|
19
|
+
JOBS_DEFAULT_EXPIRES_SEC = 900 # 15 minutes default expiration
|
20
|
+
JOBS_DEFAULT_MAX_RETRIES = 3
|
21
|
+
JOBS_DEFAULT_BACKOFF_BASE = 2.0 # Exponential backoff base
|
22
|
+
JOBS_DEFAULT_BACKOFF_MAX = 3600 # Max backoff 1 hour
|
23
|
+
|
24
|
+
# Limits
|
25
|
+
JOBS_PAYLOAD_MAX_BYTES = 1048576 # 1MB max payload size
|
26
|
+
JOBS_STREAM_MAXLEN = 100000 # Max messages per stream
|
27
|
+
JOBS_LOCAL_QUEUE_MAXSIZE = 1000 # Max local queue size
|
28
|
+
|
29
|
+
# Timeouts
|
30
|
+
JOBS_IDLE_TIMEOUT_MS = 60000 # Consider job stuck after 1 minute idle
|
31
|
+
JOBS_XPENDING_IDLE_MS = 60000 # Reclaim jobs idle for 1 minute
|
32
|
+
JOBS_RUNNER_HEARTBEAT_SEC = 5 # Heartbeat interval
|
33
|
+
JOBS_SCHEDULER_LOCK_TTL_MS = 5000 # Scheduler leadership lock TTL
|
34
|
+
|
35
|
+
# Webhook-specific Configuration
|
36
|
+
JOBS_WEBHOOK_MAX_RETRIES = 5 # More retries for webhooks (network issues)
|
37
|
+
JOBS_WEBHOOK_DEFAULT_TIMEOUT = 30 # Default webhook timeout in seconds
|
38
|
+
JOBS_WEBHOOK_MAX_TIMEOUT = 300 # Maximum allowed webhook timeout
|
39
|
+
JOBS_WEBHOOK_USER_AGENT = "Django-MOJO-Webhook/1.0" # Default User-Agent header
|
40
|
+
|
41
|
+
# Channels Configuration
|
42
|
+
JOBS_CHANNELS = [
|
43
|
+
'default',
|
44
|
+
'emails',
|
45
|
+
'uploads',
|
46
|
+
'webhooks',
|
47
|
+
'maintenance',
|
48
|
+
'reports'
|
49
|
+
]
|
50
|
+
|
51
|
+
# Example Full Configuration
|
52
|
+
"""
|
53
|
+
# In your Django settings.py:
|
54
|
+
|
55
|
+
# Basic Configuration
|
56
|
+
JOBS_REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
57
|
+
JOBS_ENGINE_MAX_WORKERS = 20 # Process 20 jobs in parallel
|
58
|
+
|
59
|
+
# High-throughput Configuration
|
60
|
+
JOBS_ENGINE_MAX_WORKERS = 50
|
61
|
+
JOBS_ENGINE_CLAIM_BUFFER = 3 # Can claim up to 150 jobs
|
62
|
+
JOBS_ENGINE_CLAIM_BATCH = 10 # Claim 10 at a time
|
63
|
+
|
64
|
+
# Conservative Configuration
|
65
|
+
JOBS_ENGINE_MAX_WORKERS = 5
|
66
|
+
JOBS_DEFAULT_MAX_RETRIES = 5
|
67
|
+
JOBS_DEFAULT_EXPIRES_SEC = 1800 # 30 minutes
|
68
|
+
|
69
|
+
# Channel-specific Workers
|
70
|
+
# Run different workers for different channels:
|
71
|
+
# python manage.py jobs_engine --channels emails,notifications --max-workers 20
|
72
|
+
# python manage.py jobs_engine --channels uploads --max-workers 5
|
73
|
+
# python manage.py jobs_engine --channels maintenance --max-workers 2
|
74
|
+
"""
|
75
|
+
|
76
|
+
# Settings Documentation
|
77
|
+
"""
|
78
|
+
Configuration Options:
|
79
|
+
|
80
|
+
JOBS_REDIS_URL
|
81
|
+
Redis connection URL. Supports standard Redis URL format.
|
82
|
+
Default: "redis://localhost:6379/0"
|
83
|
+
|
84
|
+
JOBS_REDIS_PREFIX
|
85
|
+
Prefix for all Redis keys used by the jobs system.
|
86
|
+
Default: "mojo:jobs"
|
87
|
+
|
88
|
+
JOBS_ENGINE_MAX_WORKERS
|
89
|
+
Maximum number of threads in the job engine's thread pool.
|
90
|
+
Controls how many jobs can run in parallel per engine.
|
91
|
+
Default: 10
|
92
|
+
|
93
|
+
JOBS_ENGINE_CLAIM_BUFFER
|
94
|
+
Buffer multiplier for job claiming. Engine can claim up to
|
95
|
+
max_workers * claim_buffer jobs to keep the thread pool busy.
|
96
|
+
Default: 2
|
97
|
+
|
98
|
+
JOBS_ENGINE_CLAIM_BATCH
|
99
|
+
Maximum number of jobs to claim in a single XREADGROUP call.
|
100
|
+
Prevents claiming too many jobs at once.
|
101
|
+
Default: 5
|
102
|
+
|
103
|
+
JOBS_ENGINE_READ_TIMEOUT
|
104
|
+
Timeout in milliseconds for XREADGROUP blocking reads.
|
105
|
+
Lower values = more responsive to shutdown, higher = less CPU.
|
106
|
+
Default: 100
|
107
|
+
|
108
|
+
JOBS_DEFAULT_CHANNEL
|
109
|
+
Default channel for jobs if not specified.
|
110
|
+
Default: "default"
|
111
|
+
|
112
|
+
JOBS_DEFAULT_EXPIRES_SEC
|
113
|
+
Default expiration time in seconds for jobs.
|
114
|
+
Jobs not executed within this time are marked as expired.
|
115
|
+
Default: 900 (15 minutes)
|
116
|
+
|
117
|
+
JOBS_DEFAULT_MAX_RETRIES
|
118
|
+
Default maximum retry attempts for failed jobs.
|
119
|
+
Default: 3
|
120
|
+
|
121
|
+
JOBS_DEFAULT_BACKOFF_BASE
|
122
|
+
Base for exponential backoff calculation.
|
123
|
+
Retry delay = backoff_base ^ attempt (capped at backoff_max).
|
124
|
+
Default: 2.0
|
125
|
+
|
126
|
+
JOBS_DEFAULT_BACKOFF_MAX
|
127
|
+
Maximum backoff time in seconds between retries.
|
128
|
+
Default: 3600 (1 hour)
|
129
|
+
|
130
|
+
JOBS_PAYLOAD_MAX_BYTES
|
131
|
+
Maximum size in bytes for job payloads.
|
132
|
+
Larger payloads will be rejected at publish time.
|
133
|
+
Default: 1048576 (1MB)
|
134
|
+
|
135
|
+
JOBS_STREAM_MAXLEN
|
136
|
+
Maximum length of Redis streams. Older messages are trimmed.
|
137
|
+
Uses approximate trimming for performance.
|
138
|
+
Default: 100000
|
139
|
+
|
140
|
+
JOBS_LOCAL_QUEUE_MAXSIZE
|
141
|
+
Maximum size of the local in-process job queue.
|
142
|
+
Default: 1000
|
143
|
+
|
144
|
+
JOBS_IDLE_TIMEOUT_MS
|
145
|
+
Time in milliseconds before a claimed job is considered stuck.
|
146
|
+
Used for health monitoring and potential job reclamation.
|
147
|
+
Default: 60000 (1 minute)
|
148
|
+
|
149
|
+
JOBS_XPENDING_IDLE_MS
|
150
|
+
Time in milliseconds before attempting to reclaim idle jobs
|
151
|
+
from dead/stuck workers using XCLAIM.
|
152
|
+
Default: 60000 (1 minute)
|
153
|
+
|
154
|
+
JOBS_RUNNER_HEARTBEAT_SEC
|
155
|
+
Interval in seconds between runner heartbeat updates.
|
156
|
+
Used to detect dead runners.
|
157
|
+
Default: 5
|
158
|
+
|
159
|
+
JOBS_SCHEDULER_LOCK_TTL_MS
|
160
|
+
TTL in milliseconds for the scheduler leadership lock.
|
161
|
+
Only one scheduler should be active cluster-wide.
|
162
|
+
Default: 5000 (5 seconds)
|
163
|
+
|
164
|
+
JOBS_CHANNELS
|
165
|
+
List of configured channels. Used by scheduler and manager
|
166
|
+
to know which channels to monitor.
|
167
|
+
Default: ['default']
|
168
|
+
|
169
|
+
Redis Keys (KISS approach)
|
170
|
+
With the KISS design, Redis is used for transport and timing only (Postgres is the source of truth).
|
171
|
+
- Scheduling uses two ZSETs per channel (prefixed by JOBS_REDIS_PREFIX):
|
172
|
+
• sched:{channel} for non-broadcast delayed jobs
|
173
|
+
• sched_broadcast:{channel} for broadcast delayed jobs
|
174
|
+
The ZSET score is the scheduled time in epoch milliseconds (run_at_ms).
|
175
|
+
- Immediate jobs are written directly to streams:
|
176
|
+
• stream:{channel}
|
177
|
+
• stream:{channel}:broadcast
|
178
|
+
- To pause a channel during maintenance, a pause flag key is set:
|
179
|
+
• channel:{channel}:paused (value "1" when paused)
|
180
|
+
"""
|
181
|
+
|
182
|
+
# Performance Tuning Guide
|
183
|
+
"""
|
184
|
+
Performance Tuning:
|
185
|
+
|
186
|
+
For High Throughput (10,000+ jobs/minute):
|
187
|
+
JOBS_ENGINE_MAX_WORKERS = 50-100
|
188
|
+
JOBS_ENGINE_CLAIM_BUFFER = 3
|
189
|
+
JOBS_ENGINE_CLAIM_BATCH = 20
|
190
|
+
JOBS_STREAM_MAXLEN = 500000
|
191
|
+
# Run multiple engine instances
|
192
|
+
|
193
|
+
For Low Latency (< 100ms pickup time):
|
194
|
+
JOBS_ENGINE_READ_TIMEOUT = 10-50
|
195
|
+
JOBS_ENGINE_CLAIM_BATCH = 1-2
|
196
|
+
JOBS_RUNNER_HEARTBEAT_SEC = 2
|
197
|
+
|
198
|
+
For Resource Constrained:
|
199
|
+
JOBS_ENGINE_MAX_WORKERS = 5
|
200
|
+
JOBS_ENGINE_CLAIM_BUFFER = 1
|
201
|
+
JOBS_PAYLOAD_MAX_BYTES = 102400 # 100KB
|
202
|
+
JOBS_STREAM_MAXLEN = 10000
|
203
|
+
|
204
|
+
For Reliability:
|
205
|
+
JOBS_DEFAULT_MAX_RETRIES = 5-10
|
206
|
+
JOBS_DEFAULT_EXPIRES_SEC = 3600 # 1 hour
|
207
|
+
JOBS_IDLE_TIMEOUT_MS = 300000 # 5 minutes
|
208
|
+
JOBS_DEFAULT_BACKOFF_MAX = 7200 # 2 hours
|
209
|
+
"""
|
mojo/apps/logit/models/log.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
from mojo.models import MojoModel
|
2
2
|
from django.db import models as dm
|
3
3
|
from mojo.helpers import logit
|
4
|
+
import ujson
|
4
5
|
# logger = logit.get_logger("requests", "requests.log")
|
5
6
|
|
6
7
|
|
@@ -23,6 +24,8 @@ class Log(dm.Model, MojoModel):
|
|
23
24
|
|
24
25
|
@classmethod
|
25
26
|
def logit(cls, request, log, kind="log", model_name=None, model_id=0, level="info", **kwargs):
|
27
|
+
if isinstance(log, dict):
|
28
|
+
log = ujson.encode(log, indent=4)
|
26
29
|
if not isinstance(log, (bytes, str)):
|
27
30
|
log = f"INVALID LOG TYPE: attempting to log type: {type(log)}"
|
28
31
|
log = log.decode("utf-8") if isinstance(log, bytes) else log
|