django-ray 0.1.0__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_ray/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Django Ray - Ray.io integration with Django for distributed computing."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ # Default app config for Django
6
+ default_app_config = "django_ray.apps.DjangoRayConfig"
django_ray/admin.py ADDED
@@ -0,0 +1,461 @@
1
+ """Django admin configuration for django-ray."""
2
+
3
+ from typing import Any
4
+
5
+ from django.contrib import admin
6
+ from django.db.models import QuerySet
7
+ from django.http import HttpRequest
8
+ from django.utils import timezone
9
+ from django.utils.safestring import mark_safe
10
+
11
+ from django_ray.models import RayTaskExecution, TaskState, TaskWorkerLease
12
+
13
+ # Ray Dashboard URL - can be overridden via Django settings
14
+ RAY_DASHBOARD_URL = "http://localhost:30265"
15
+
16
+
17
+ @admin.register(RayTaskExecution)
18
+ class RayTaskExecutionAdmin(admin.ModelAdmin):
19
+ """Admin for RayTaskExecution model."""
20
+
21
+ list_display = [
22
+ "id",
23
+ "callable_path",
24
+ "state_display",
25
+ "queue_name",
26
+ "attempt_number",
27
+ "ray_dashboard_link",
28
+ "created_at",
29
+ "started_at",
30
+ "finished_at",
31
+ ]
32
+ list_filter = [
33
+ "state",
34
+ "queue_name",
35
+ "created_at",
36
+ ]
37
+ search_fields = [
38
+ "task_id",
39
+ "callable_path",
40
+ "ray_job_id",
41
+ ]
42
+ readonly_fields = [
43
+ "task_id",
44
+ "callable_path",
45
+ "ray_job_id_display",
46
+ "ray_address",
47
+ "created_at",
48
+ "started_at",
49
+ "finished_at",
50
+ "last_heartbeat_at",
51
+ "args_json",
52
+ "kwargs_json",
53
+ "result_data",
54
+ "error_message",
55
+ "error_traceback",
56
+ ]
57
+ fieldsets = (
58
+ (
59
+ "Task Info",
60
+ {
61
+ "fields": ("task_id", "callable_path", "queue_name", "state", "attempt_number"),
62
+ },
63
+ ),
64
+ (
65
+ "Arguments",
66
+ {
67
+ "fields": ("args_json", "kwargs_json"),
68
+ "classes": ("collapse",),
69
+ },
70
+ ),
71
+ (
72
+ "Result",
73
+ {
74
+ "fields": ("result_data", "error_message", "error_traceback"),
75
+ },
76
+ ),
77
+ (
78
+ "Ray Execution",
79
+ {
80
+ "fields": ("ray_job_id_display", "ray_address", "claimed_by_worker"),
81
+ "description": "Ray Job ID is only available for Ray Job API mode.",
82
+ },
83
+ ),
84
+ (
85
+ "Timing",
86
+ {
87
+ "fields": ("created_at", "started_at", "finished_at", "last_heartbeat_at"),
88
+ },
89
+ ),
90
+ )
91
+ ordering = ["-created_at"]
92
+ actions = ["retry_tasks", "cancel_tasks"]
93
+
94
+ @admin.display(description="Ray Job ID")
95
+ def ray_job_id_display(self, obj: RayTaskExecution) -> str:
96
+ """Display Ray Job ID with link to Ray Dashboard."""
97
+ from django.conf import settings
98
+
99
+ ray_job_id = obj.ray_job_id
100
+ if not ray_job_id:
101
+ return "Not yet submitted"
102
+
103
+ job_id = str(ray_job_id)
104
+ dashboard_url = getattr(settings, "RAY_DASHBOARD_URL", RAY_DASHBOARD_URL)
105
+
106
+ # Old format: ray_core:pk
107
+ if job_id.startswith("ray_core:"):
108
+ return "N/A (legacy format)"
109
+
110
+ # New format: job_id:task_id (e.g., "02000000:67a2e8cfa5...")
111
+ if ":" in job_id and not job_id.startswith("raysubmit_"):
112
+ parts = job_id.split(":", 1)
113
+ ray_job = parts[0]
114
+ ray_task = parts[1]
115
+ url = f"{dashboard_url}/#/jobs/{ray_job}/tasks/{ray_task}"
116
+ return mark_safe(
117
+ f"Job: {ray_job}, Task: {ray_task[:16]}... "
118
+ f'<a href="{url}" target="_blank">[Open in Dashboard]</a>'
119
+ )
120
+
121
+ # Ray Job API format
122
+ url = f"{dashboard_url}/#/jobs/{job_id}"
123
+ return mark_safe(f'{job_id} <a href="{url}" target="_blank">[Open in Dashboard]</a>')
124
+
125
+ @admin.display(description="State", ordering="state")
126
+ def state_display(self, obj: RayTaskExecution) -> str:
127
+ """Display state with color coding."""
128
+ colors: dict[str, str] = {
129
+ TaskState.QUEUED: "#6c757d",
130
+ TaskState.RUNNING: "#007bff",
131
+ TaskState.SUCCEEDED: "#28a745",
132
+ TaskState.FAILED: "#dc3545",
133
+ TaskState.CANCELLED: "#ffc107",
134
+ TaskState.CANCELLING: "#ffc107",
135
+ TaskState.LOST: "#dc3545",
136
+ }
137
+ state = str(obj.state)
138
+ color = colors.get(state, "#6c757d")
139
+ # state is from TaskState constants, not user input, so mark_safe is appropriate
140
+ return mark_safe(f'<span style="color: {color}; font-weight: bold;">{state}</span>')
141
+
142
+ @admin.display(description="Ray Dashboard")
143
+ def ray_dashboard_link(self, obj: RayTaskExecution) -> str:
144
+ """Display link to Ray Dashboard for the job/task."""
145
+ from django.conf import settings
146
+
147
+ ray_job_id = obj.ray_job_id
148
+ if not ray_job_id:
149
+ return "-"
150
+
151
+ job_id = str(ray_job_id)
152
+
153
+ # Get dashboard URL from settings or use default
154
+ dashboard_url = getattr(settings, "RAY_DASHBOARD_URL", RAY_DASHBOARD_URL)
155
+
156
+ # Old format: ray_core:pk - no useful link
157
+ if job_id.startswith("ray_core:"):
158
+ url = f"{dashboard_url}/#/jobs"
159
+ return mark_safe(f'<a href="{url}" target="_blank">Jobs</a>')
160
+
161
+ # New format: job_id:task_id (e.g., "02000000:67a2e8cfa5...")
162
+ if ":" in job_id and not job_id.startswith("raysubmit_"):
163
+ parts = job_id.split(":", 1)
164
+ ray_job = parts[0]
165
+ ray_task = parts[1]
166
+ # Link directly to the task in the Ray Dashboard
167
+ url = f"{dashboard_url}/#/jobs/{ray_job}/tasks/{ray_task}"
168
+ return mark_safe(f'<a href="{url}" target="_blank">Task</a>')
169
+
170
+ # Ray Job API - link to the job
171
+ url = f"{dashboard_url}/#/jobs/{job_id}"
172
+ return mark_safe(f'<a href="{url}" target="_blank">{job_id[:8]}...</a>')
173
+
174
+ @admin.action(description="Retry selected tasks")
175
+ def retry_tasks(self, request: HttpRequest, queryset: QuerySet[RayTaskExecution]) -> None:
176
+ """Retry failed or lost tasks by resetting them to QUEUED state."""
177
+ retryable_states = [TaskState.FAILED, TaskState.LOST]
178
+ tasks_to_retry = queryset.filter(state__in=retryable_states)
179
+ count = tasks_to_retry.count()
180
+
181
+ if count == 0:
182
+ self.message_user(
183
+ request,
184
+ "No failed or lost tasks found in selection.",
185
+ )
186
+ return
187
+
188
+ # Reset tasks to QUEUED state for retry
189
+ # TODO: In the future, we could preserve attempt history in a separate model
190
+ # to track each run's result/error instead of overwriting
191
+ tasks_to_retry.update(
192
+ state=TaskState.QUEUED,
193
+ attempt_number=0,
194
+ result_data=None,
195
+ error_message=None,
196
+ error_traceback=None,
197
+ started_at=None,
198
+ finished_at=None,
199
+ last_heartbeat_at=None,
200
+ claimed_by_worker=None,
201
+ ray_job_id=None,
202
+ )
203
+
204
+ self.message_user(
205
+ request,
206
+ f"Queued {count} task(s) for retry.",
207
+ )
208
+
209
+ @admin.action(description="Cancel selected tasks")
210
+ def cancel_tasks(self, request: HttpRequest, queryset: QuerySet[RayTaskExecution]) -> None:
211
+ """Cancel queued or running tasks.
212
+
213
+ For QUEUED tasks: Marks as CANCELLED immediately.
214
+ For RUNNING tasks with Ray Job API: Attempts to stop the Ray job.
215
+ For RUNNING tasks with Ray Core: Marks as CANCELLED (worker will skip on next poll).
216
+ """
217
+ cancellable_states = [TaskState.QUEUED, TaskState.RUNNING]
218
+ tasks_to_cancel = queryset.filter(state__in=cancellable_states)
219
+
220
+ if not tasks_to_cancel.exists():
221
+ self.message_user(
222
+ request,
223
+ "No queued or running tasks found in selection.",
224
+ )
225
+ return
226
+
227
+ cancelled_count = 0
228
+ ray_job_cancel_attempted = 0
229
+
230
+ for task in tasks_to_cancel:
231
+ now = timezone.now()
232
+
233
+ if task.state == TaskState.QUEUED:
234
+ # Queued tasks can be cancelled directly
235
+ task.state = TaskState.CANCELLED
236
+ task.finished_at = now
237
+ task.save(update_fields=["state", "finished_at"])
238
+ cancelled_count += 1
239
+
240
+ elif task.state == TaskState.RUNNING:
241
+ # Check if this is a Ray Job API task
242
+ # Ray Job API: starts with "raysubmit_"
243
+ # Ray Core old format: starts with "ray_core:"
244
+ # Ray Core new format: "job_id:task_id" (neither starts with raysubmit_ nor ray_core:)
245
+ ray_job_id = task.ray_job_id
246
+ is_ray_job_api = ray_job_id and str(ray_job_id).startswith("raysubmit_")
247
+
248
+ if is_ray_job_api:
249
+ # Try to stop the Ray job
250
+ try:
251
+ from django_ray.runner.ray_job import RayJobRunner
252
+
253
+ runner = RayJobRunner()
254
+ from datetime import UTC, datetime
255
+
256
+ from django_ray.runner.base import SubmissionHandle
257
+
258
+ handle = SubmissionHandle(
259
+ ray_job_id=str(ray_job_id),
260
+ ray_address=str(task.ray_address or ""),
261
+ submitted_at=task.started_at or datetime.now(UTC),
262
+ )
263
+ runner.cancel(handle)
264
+ ray_job_cancel_attempted += 1
265
+ except Exception:
266
+ pass # Best effort
267
+
268
+ # Mark as CANCELLING - worker will finalize on next poll
269
+ task.state = TaskState.CANCELLING
270
+ task.save(update_fields=["state"])
271
+ cancelled_count += 1
272
+
273
+ message = f"Marked {cancelled_count} task(s) for cancellation."
274
+ if ray_job_cancel_attempted:
275
+ message += f" Attempted to stop {ray_job_cancel_attempted} Ray job(s)."
276
+
277
+ self.message_user(request, message)
278
+
279
+
280
+ class ActiveWorkerFilter(admin.SimpleListFilter):
281
+ """Filter to show active/inactive workers with active as default."""
282
+
283
+ title = "status"
284
+ parameter_name = "is_active"
285
+
286
+ def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin) -> list[tuple[str, str]]:
287
+ return [
288
+ ("active", "Active"),
289
+ ("inactive", "Inactive"),
290
+ ("all", "All"),
291
+ ]
292
+
293
+ def queryset(
294
+ self, request: HttpRequest, queryset: QuerySet[TaskWorkerLease]
295
+ ) -> QuerySet[TaskWorkerLease]:
296
+ if self.value() == "inactive":
297
+ return queryset.filter(is_active=False)
298
+ elif self.value() == "all":
299
+ return queryset
300
+ else:
301
+ # Default: show only active
302
+ return queryset.filter(is_active=True)
303
+
304
+ def choices(self, changelist: Any) -> Any:
305
+ """Override to set default selection."""
306
+ for lookup, title in self.lookup_choices:
307
+ yield {
308
+ "selected": self.value() == lookup or (self.value() is None and lookup == "active"),
309
+ "query_string": changelist.get_query_string({self.parameter_name: lookup}),
310
+ "display": title,
311
+ }
312
+
313
+
314
+ @admin.register(TaskWorkerLease)
315
+ class TaskWorkerLeaseAdmin(admin.ModelAdmin):
316
+ """Admin for TaskWorkerLease model.
317
+
318
+ Note: This tracks Django task workers (django_ray_worker command),
319
+ NOT Ray cluster workers. The Django workers claim tasks from the
320
+ database and submit them to Ray for execution.
321
+
322
+ By default, only active workers are shown. Use the filter to see inactive workers.
323
+ """
324
+
325
+ list_display = [
326
+ "worker_id_short",
327
+ "hostname",
328
+ "pid",
329
+ "queue_name",
330
+ "started_at",
331
+ "last_heartbeat_at",
332
+ "is_active_display_list",
333
+ "time_since_heartbeat",
334
+ ]
335
+ list_filter = [
336
+ ActiveWorkerFilter,
337
+ "queue_name",
338
+ "hostname",
339
+ ]
340
+ search_fields = [
341
+ "worker_id",
342
+ "hostname",
343
+ ]
344
+ readonly_fields = [
345
+ "worker_id",
346
+ "hostname",
347
+ "pid",
348
+ "queue_name",
349
+ "started_at",
350
+ "last_heartbeat_at",
351
+ "stopped_at",
352
+ "is_active",
353
+ ]
354
+ fieldsets = (
355
+ (
356
+ "Worker Identification",
357
+ {
358
+ "fields": ("worker_id", "hostname", "pid"),
359
+ },
360
+ ),
361
+ (
362
+ "Configuration",
363
+ {
364
+ "fields": ("queue_name",),
365
+ "description": "Note: Changing the queue here does NOT affect the worker. "
366
+ "The queue is set when the worker starts via --queue argument.",
367
+ },
368
+ ),
369
+ (
370
+ "Status",
371
+ {
372
+ "fields": ("is_active",),
373
+ },
374
+ ),
375
+ (
376
+ "Timing",
377
+ {
378
+ "fields": ("started_at", "last_heartbeat_at", "stopped_at"),
379
+ },
380
+ ),
381
+ )
382
+ actions = ["mark_inactive", "delete_inactive"]
383
+
384
+ def get_queryset(self, request: HttpRequest) -> QuerySet[TaskWorkerLease]:
385
+ """Default queryset - filter applied via ActiveWorkerFilter."""
386
+ return super().get_queryset(request)
387
+
388
+ @admin.display(description="Worker ID")
389
+ def worker_id_short(self, obj: TaskWorkerLease) -> str:
390
+ """Show shortened worker ID."""
391
+ worker_id = str(obj.worker_id)
392
+ return f"{worker_id[:12]}..."
393
+
394
+ @admin.display(boolean=True, description="Active")
395
+ def is_active_display_list(self, obj: TaskWorkerLease) -> bool:
396
+ """Display active status as boolean icon in list view."""
397
+ return bool(obj.is_active) and not self._is_heartbeat_expired(obj)
398
+
399
+ @staticmethod
400
+ def _is_heartbeat_expired(obj: TaskWorkerLease) -> bool:
401
+ """Check if heartbeat has expired."""
402
+ from django_ray.runner.leasing import is_lease_expired
403
+
404
+ return is_lease_expired(obj)
405
+
406
+ @admin.display(description="Time Since Heartbeat")
407
+ def time_since_heartbeat(self, obj: TaskWorkerLease) -> str:
408
+ """Show time since last heartbeat."""
409
+ if not obj.last_heartbeat_at:
410
+ return "Never"
411
+ delta = timezone.now() - obj.last_heartbeat_at
412
+ seconds = int(delta.total_seconds())
413
+ if seconds < 60:
414
+ return f"{seconds}s ago"
415
+ elif seconds < 3600:
416
+ return f"{seconds // 60}m {seconds % 60}s ago"
417
+ else:
418
+ return f"{seconds // 3600}h {(seconds % 3600) // 60}m ago"
419
+
420
+ @admin.action(description="Mark selected as inactive")
421
+ def mark_inactive(self, request: HttpRequest, queryset: QuerySet[TaskWorkerLease]) -> None:
422
+ """Mark selected worker leases as inactive."""
423
+ count = queryset.filter(is_active=True).update(
424
+ is_active=False,
425
+ stopped_at=timezone.now(),
426
+ )
427
+
428
+ if count > 0:
429
+ self.message_user(
430
+ request,
431
+ f"Marked {count} worker lease(s) as inactive.",
432
+ )
433
+ else:
434
+ self.message_user(
435
+ request,
436
+ "No active leases found in selection.",
437
+ )
438
+
439
+ @admin.action(description="Delete inactive worker leases")
440
+ def delete_inactive(self, request: HttpRequest, queryset: QuerySet[TaskWorkerLease]) -> None:
441
+ """Delete inactive worker leases from selected."""
442
+ deleted_count, _ = queryset.filter(is_active=False).delete()
443
+
444
+ if deleted_count > 0:
445
+ self.message_user(
446
+ request,
447
+ f"Deleted {deleted_count} inactive worker lease(s).",
448
+ )
449
+ else:
450
+ self.message_user(
451
+ request,
452
+ "No inactive leases found in selection.",
453
+ )
454
+
455
+ def has_add_permission(self, request: HttpRequest) -> bool:
456
+ """Disable adding leases manually - workers create their own."""
457
+ return False
458
+
459
+ def has_change_permission(self, request: HttpRequest, obj: Any = None) -> bool:
460
+ """Disable editing leases - they are managed by workers."""
461
+ return False
django_ray/apps.py ADDED
@@ -0,0 +1,23 @@
1
+ """Django app configuration for django-ray."""
2
+
3
+ from django.apps import AppConfig
4
+
5
+
6
+ class DjangoRayConfig(AppConfig):
7
+ """Configuration for django-ray Django app."""
8
+
9
+ name = "django_ray"
10
+ verbose_name = "Django Ray"
11
+ default_auto_field = "django.db.models.BigAutoField"
12
+
13
+ def ready(self) -> None:
14
+ """Initialize the app when Django starts."""
15
+ from django.core.exceptions import ImproperlyConfigured
16
+
17
+ from django_ray.conf.settings import validate_settings
18
+
19
+ try:
20
+ validate_settings()
21
+ except ImproperlyConfigured:
22
+ # Allow startup without RAY_ADDRESS for migrations, etc.
23
+ pass