-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathtest_cleanup.py
More file actions
215 lines (174 loc) · 8.17 KB
/
Copy pathtest_cleanup.py
File metadata and controls
215 lines (174 loc) · 8.17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
"""Integration tests for async job resource cleanup (NATS and Redis)."""
from asgiref.sync import async_to_sync
from django.core.cache import cache
from django.test import TestCase
from nats.js.errors import NotFoundError
from ami.jobs.models import Job, JobDispatchMode, JobState, MLJob
from ami.jobs.tasks import _update_job_progress, update_job_failure, update_job_status
from ami.main.models import Project, ProjectFeatureFlags, SourceImage, SourceImageCollection
from ami.ml.models import Pipeline
from ami.ml.orchestration.jobs import queue_images_to_nats
from ami.ml.orchestration.nats_queue import TaskQueueManager
from ami.ml.orchestration.task_state import TaskStateManager
class TestCleanupAsyncJobResources(TestCase):
"""Test cleanup of NATS and Redis resources for async ML jobs."""
def setUp(self):
"""Set up test fixtures with async_pipeline_workers enabled."""
# Create project with async_pipeline_workers feature flag enabled
self.project = Project.objects.create(
name="Test Cleanup Project",
feature_flags=ProjectFeatureFlags(async_pipeline_workers=True),
)
# Create pipeline
self.pipeline = Pipeline.objects.create(
name="Test Cleanup Pipeline",
slug="test-cleanup-pipeline",
description="Pipeline for cleanup tests",
)
self.pipeline.projects.add(self.project)
# Create source image collection with images
self.collection = SourceImageCollection.objects.create(
name="Test Cleanup Collection",
project=self.project,
)
# Create test images
self.images = [
SourceImage.objects.create(
path=f"test_image_{i}.jpg",
public_base_url="https://example.com",
project=self.project,
)
for i in range(3)
]
for image in self.images:
self.collection.images.add(image)
def _verify_resources_created(self, job_id: int):
"""
Verify that both Redis and NATS resources were created.
Args:
job_id: The job ID to check
"""
# Verify Redis keys exist
state_manager = TaskStateManager(job_id)
for stage in state_manager.STAGES:
pending_key = state_manager._get_pending_key(stage)
self.assertIsNotNone(cache.get(pending_key), f"Redis key {pending_key} should exist")
total_key = state_manager._total_key
self.assertIsNotNone(cache.get(total_key), f"Redis key {total_key} should exist")
# Verify NATS stream and consumer exist
async def check_nats_resources():
async with TaskQueueManager() as manager:
stream_name = manager._get_stream_name(job_id)
consumer_name = manager._get_consumer_name(job_id)
# Try to get stream info - should succeed if created
stream_exists = True
try:
await manager.js.stream_info(stream_name)
except NotFoundError:
stream_exists = False
# Try to get consumer info - should succeed if created
consumer_exists = True
try:
await manager.js.consumer_info(stream_name, consumer_name)
except NotFoundError:
consumer_exists = False
return stream_exists, consumer_exists
stream_exists, consumer_exists = async_to_sync(check_nats_resources)()
self.assertTrue(stream_exists, f"NATS stream for job {job_id} should exist")
self.assertTrue(consumer_exists, f"NATS consumer for job {job_id} should exist")
def _create_job_with_queued_images(self) -> Job:
"""
Helper to create an ML job and queue images to NATS/Redis.
Returns:
Job instance with images queued to NATS and state initialized in Redis
"""
job = Job.objects.create(
job_type_key=MLJob.key,
project=self.project,
name="Test Cleanup Job",
pipeline=self.pipeline,
source_image_collection=self.collection,
dispatch_mode=JobDispatchMode.ASYNC_API,
)
# Queue images to NATS (also initializes Redis state)
queue_images_to_nats(job, self.images)
# Verify resources were actually created
self._verify_resources_created(job.pk)
return job
def _verify_resources_cleaned(self, job_id: int):
"""
Verify that both Redis and NATS resources are cleaned up.
Args:
job_id: The job ID to check
"""
# Verify Redis keys are deleted
state_manager = TaskStateManager(job_id)
for stage in state_manager.STAGES:
pending_key = state_manager._get_pending_key(stage)
self.assertIsNone(cache.get(pending_key), f"Redis key {pending_key} should be deleted")
total_key = state_manager._total_key
self.assertIsNone(cache.get(total_key), f"Redis key {total_key} should be deleted")
# Verify NATS stream and consumer are deleted
async def check_nats_resources():
async with TaskQueueManager() as manager:
stream_name = manager._get_stream_name(job_id)
consumer_name = manager._get_consumer_name(job_id)
# Try to get stream info - should fail if deleted
stream_exists = True
try:
await manager.js.stream_info(stream_name)
except NotFoundError:
stream_exists = False
# Try to get consumer info - should fail if deleted
consumer_exists = True
try:
await manager.js.consumer_info(stream_name, consumer_name)
except NotFoundError:
consumer_exists = False
return stream_exists, consumer_exists
stream_exists, consumer_exists = async_to_sync(check_nats_resources)()
self.assertFalse(stream_exists, f"NATS stream for job {job_id} should be deleted")
self.assertFalse(consumer_exists, f"NATS consumer for job {job_id} should be deleted")
def test_cleanup_on_job_completion(self):
"""Test that resources are cleaned up when job completes successfully."""
job = self._create_job_with_queued_images()
# Simulate job completion: complete all stages (collect, process, then results)
_update_job_progress(job.pk, stage="collect", progress_percentage=1.0)
_update_job_progress(job.pk, stage="process", progress_percentage=1.0)
_update_job_progress(job.pk, stage="results", progress_percentage=1.0)
# Verify cleanup happened
self._verify_resources_cleaned(job.pk)
def test_cleanup_on_job_failure(self):
"""Test that resources are cleaned up when job fails."""
job = self._create_job_with_queued_images()
# Set task_id so the failure handler can find the job
job.task_id = "test-task-failure-123"
job.save()
# Simulate job failure by calling the failure signal handler
update_job_failure(
sender=None,
task_id=job.task_id,
exception=Exception("Test failure"),
)
# Verify cleanup happened
self._verify_resources_cleaned(job.pk)
def test_cleanup_on_job_revoked(self):
"""Test that resources are cleaned up when job is revoked/cancelled."""
job = self._create_job_with_queued_images()
# Create a mock task request object for the signal handler
class MockRequest:
def __init__(self):
self.kwargs = {"job_id": job.pk}
class MockTask:
def __init__(self, job_id):
self.request = MockRequest()
self.request.kwargs["job_id"] = job_id
# Simulate job revocation by calling the postrun signal handler with REVOKED state
update_job_status(
sender=None,
task_id="test-task-revoked-456",
task=MockTask(job.pk),
state=JobState.REVOKED,
)
# Verify cleanup happened
self._verify_resources_cleaned(job.pk)