Skip to content

Commit 9990aca

Browse files
ActiveChooNmaxxgx
andauthored
feat(inspect): Model deletion endpoint (#3128)
* feat(inspect): add endpoint to delete models and their artifacts Signed-off-by: Dmitry Kalinin <dmitry.kalinin@intel.com> * Fixed linter Signed-off-by: Dmitry Kalinin <dmitry.kalinin@intel.com> * Update application/backend/src/services/model_service.py Co-authored-by: Max Xiang <maxx.rift@gmail.com> Signed-off-by: Dmitry Kalinin <dmitry.kalinin@intel.com> * Fixed comments Signed-off-by: Dmitry Kalinin <dmitry.kalinin@intel.com> * Fixed linter Signed-off-by: Dmitry Kalinin <dmitry.kalinin@intel.com> * Fixed comments Signed-off-by: Dmitry Kalinin <dmitry.kalinin@intel.com> * Fixed tests Signed-off-by: Dmitry Kalinin <dmitry.kalinin@intel.com> * Update application/backend/src/services/training_service.py Co-authored-by: Max Xiang <maxx.rift@gmail.com> Signed-off-by: Dmitry Kalinin <dmitry.kalinin@intel.com> * Fixed tests Signed-off-by: Dmitry Kalinin <dmitry.kalinin@intel.com> * Fixed tests Signed-off-by: Dmitry Kalinin <dmitry.kalinin@intel.com> --------- Signed-off-by: Dmitry Kalinin <dmitry.kalinin@intel.com> Co-authored-by: Max Xiang <maxx.rift@gmail.com>
1 parent 0773110 commit 9990aca

File tree

6 files changed

+59
-29
lines changed

6 files changed

+59
-29
lines changed

application/backend/src/api/endpoints/model_endpoints.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,21 @@ async def predict(
6868
return await model_service.predict_image(model, image_bytes, request.app.state.active_models, device=device)
6969
except DeviceNotFoundError as e:
7070
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
71+
72+
73+
@model_router.delete(
74+
"/{model_id}",
75+
status_code=status.HTTP_204_NO_CONTENT,
76+
responses={
77+
status.HTTP_204_NO_CONTENT: {"description": "Model and exported artifacts successfully deleted"},
78+
status.HTTP_400_BAD_REQUEST: {"description": "Invalid model ID"},
79+
status.HTTP_404_NOT_FOUND: {"description": "Model not found"},
80+
},
81+
)
82+
async def delete_model(
83+
model_service: Annotated[ModelService, Depends(get_model_service)],
84+
project_id: Annotated[UUID, Depends(get_project_id)],
85+
model_id: Annotated[UUID, Depends(get_model_id)],
86+
) -> None:
87+
"""Delete a model and any exported artifacts."""
88+
await model_service.delete_model(project_id=project_id, model_id=model_id)

application/backend/src/services/model_service.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,22 @@ async def get_model_by_id(project_id: UUID, model_id: UUID) -> Model | None:
7777
repo = ModelRepository(session, project_id=project_id)
7878
return await repo.get_by_id(model_id)
7979

80-
@staticmethod
81-
async def delete_model(project_id: UUID, model_id: UUID) -> None:
80+
async def delete_model(self, project_id: UUID, model_id: UUID, delete_artifacts: bool = True) -> None:
81+
if delete_artifacts:
82+
model_binary_repo = ModelBinaryRepository(project_id=project_id, model_id=model_id)
83+
try:
84+
await model_binary_repo.delete_model_folder()
85+
except FileNotFoundError:
86+
logger.warning(
87+
"Model artifacts already absent on disk for model %s in project %s", model_id, project_id
88+
)
89+
8290
async with get_async_db_session_ctx() as session:
8391
repo = ModelRepository(session, project_id=project_id)
8492
return await repo.delete_by_id(model_id)
8593

94+
self.activate_model()
95+
8696
@classmethod
8797
async def load_inference_model(cls, model: Model, device: str | None = None) -> OpenVINOInferencer:
8898
"""Load a model for inference using the anomalib OpenVINO inferencer.

application/backend/src/services/training_service.py

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,8 @@ async def _run_training_job(cls, job: Job, job_service: JobService) -> Model | N
109109
job_id=job.id, status=JobStatus.FAILED, message=f"Failed with exception: {str(e)}"
110110
)
111111
if model.export_path:
112-
logger.warning("Deleting partially created model with id: %s", model.id)
113-
await cls._cleanup_partial_model(
114-
job=job,
115-
model=model,
116-
delete_model_record=True,
117-
model_service=model_service,
118-
)
112+
logger.warning(f"Deleting partially created model with id: {model.id}")
113+
await model_service.delete_model(project_id=project_id, model_id=model.id, delete_artifacts=True)
119114
raise e
120115
finally:
121116
logger.debug("Syncing progress with db stopped")
@@ -218,27 +213,10 @@ async def _handle_job_cancellation(job_service: JobService, job: Job, model: Mod
218213
status=JobStatus.CANCELED,
219214
message="Training cancelled by user",
220215
)
221-
await TrainingService._cleanup_partial_model(job=job, model=model, delete_model_record=False)
222-
223-
@staticmethod
224-
async def _cleanup_partial_model(
225-
*,
226-
job: Job,
227-
model: Model,
228-
delete_model_record: bool,
229-
model_service: ModelService | None = None,
230-
) -> None:
231-
"""Remove partially exported artifacts and optionally delete model record."""
232-
if not model.export_path:
233-
return
234216

235217
model_binary_repo = ModelBinaryRepository(project_id=job.project_id, model_id=model.id)
236218
await model_binary_repo.delete_model_folder()
237219

238-
if delete_model_record:
239-
service = model_service or ModelService()
240-
await service.delete_model(project_id=job.project_id, model_id=model.id)
241-
242220
@staticmethod
243221
def _compute_export_size(path: str | None) -> int | None:
244222
if path is None:

application/backend/tests/unit/endpoints/test_models.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
from api.dependencies import get_model_service
1111
from main import app
1212
from pydantic_models import Model, ModelList
13-
from services import ModelService
13+
from services import ModelService, ResourceNotFoundError
14+
from services.exceptions import ResourceType
1415

1516

1617
@pytest.fixture
@@ -51,3 +52,24 @@ def test_get_models(fxt_client, fxt_model_service, fxt_model, fxt_project):
5152
assert len(response.json()["models"]) == 1
5253
assert response.json()["models"][0]["size"] == 1024
5354
fxt_model_service.get_model_list.assert_called_once_with(project_id=project_id)
55+
56+
57+
def test_delete_model_success(fxt_client, fxt_model_service, fxt_project):
58+
project_id = fxt_project.id
59+
model_id = uuid4()
60+
61+
response = fxt_client.delete(f"/api/projects/{project_id}/models/{model_id}")
62+
63+
assert response.status_code == status.HTTP_204_NO_CONTENT
64+
fxt_model_service.delete_model.assert_called_once_with(project_id=project_id, model_id=model_id)
65+
66+
67+
def test_delete_model_not_found(fxt_client, fxt_model_service, fxt_project):
68+
project_id = fxt_project.id
69+
model_id = uuid4()
70+
fxt_model_service.delete_model.side_effect = ResourceNotFoundError(ResourceType.MODEL, str(model_id))
71+
72+
response = fxt_client.delete(f"/api/projects/{project_id}/models/{model_id}")
73+
74+
assert response.status_code == status.HTTP_404_NOT_FOUND
75+
assert "not found" in response.json()["detail"].lower()

application/backend/tests/unit/services/test_model_service.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,9 @@ def test_delete_model(self, fxt_model_service, fxt_model_repository, fxt_model,
128128
with patch("services.model_service.ModelRepository") as mock_repo_class:
129129
mock_repo_class.return_value = fxt_model_repository
130130

131-
asyncio.run(fxt_model_service.delete_model(fxt_project.id, fxt_model.id))
131+
asyncio.run(
132+
fxt_model_service.delete_model(fxt_model_service, fxt_project.id, fxt_model.id, delete_artifacts=False)
133+
)
132134

133135
fxt_model_repository.delete_by_id.assert_called_once_with(fxt_model.id)
134136

application/backend/tests/unit/services/test_training_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,6 @@ def mock_train_model(cls, model, synchronization_parameters: ProgressSyncParams,
203203
asyncio.run(TrainingService.train_pending_job())
204204

205205
# Verify cleanup was called
206-
fxt_mock_binary_repos[0].return_value.delete_model_folder.assert_called_once()
207206
fxt_mock_model_service.delete_model.assert_called_once()
208207
call_args = fxt_mock_model_service.delete_model.call_args
209208
assert call_args[1]["project_id"] == fxt_job.project_id
@@ -255,6 +254,7 @@ def test_train_pending_job_cancelled(
255254
fxt_mock_job_service_class,
256255
fxt_mock_model_service_class,
257256
fxt_mock_job_service,
257+
fxt_mock_binary_repos,
258258
):
259259
"""Training should mark job as cancelled when cancellation flag is set."""
260260
fxt_job.payload = {"model_name": "padim"}

0 commit comments

Comments
 (0)