Repository: ITISFoundation/osparc-simcore
Pull request #7450: ✨Storage: batchDelete
Author: login: sanderegg
Labels: name: a:storage
description: issue related to storage service
name: a:webserver
description: webserver's codebase. Assigning the area is particularly useful for bugs
name: 🤖-automerge
description: marks PR as ready to be merged for Mergify
Created: 2025-03-31T06:57:21Z | Merged: 2025-04-01T11:54:20Z

<!-- Title Annotations:

  WIP: work in progress
  🐛    Fix a bug.
  ✨    Introduce new features.
  🎨    Enhance existing feature.
  ♻️    Refactor code.
  🚑️    Critical hotfix.
  ⚗️    Perform experiments.
  ⬆️    Upgrade dependencies.
  📝    Add or update documentation.
  🔨    Add or update development scripts.
  🔒️    Fix security issues.
  ⚠️    Changes in ops configuration etc. are required before deploying.
        [ Please add a link to the associated ops-issue or PR, such as in https://github.com/ITISFoundation/osparc-ops-environments or https://git.speag.com/oSparc/osparc-infra ]
  🗃️    Database table changed (relevant for devops).
  👽️    Public API changes (meaning: dev features are moved to being exposed in production)
  🚨    Do manual testing when deployed

or from https://gitmoji.dev/
-->

## What do these changes do?

<!-- Badge to openapi specs
[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=HERE-URL-TO-RAW-FILE)
-->
This PR adds a batch delete to the webserver REST API.

This then calls into storage via RPC and then into the storage worker via Celery.
As it is, currently this cannot be cancelled once started.

@odeimaiz it should then be ready for frontend implementation.

## Related issue/s

<!-- Link pull request to an issue
  SEE https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue

- resolves ITISFoundation/osparc-issues#428
- fixes #26
-->
- fixes https://github.com/ITISFoundation/osparc-simcore/issues/7277

## How to test

<!-- Give REVIEWERS some hits or code snippets on how could this be tested -->

## Dev-ops checklist

- [x] No ENV changes or I properly updated ENV ([read the instruction](https://git.speag.com/oSparc/osparc-ops-deployment-configuration/-/blob/configs/README.md?ref_type=heads#how-to-update-env-variables))

<!-- Some checks that might help your code run stable on production, and help devops assess criticality.
Modified from https://oschvr.com/posts/what-id-like-as-sre/

- How can DevOps check the health of the service ?
- How can DevOps safely and gracefully restart the service ?
- How and why would this code fail ?
- What kind of metrics are you exposing ?
- Is there any documentation/design specification for the service ?
- How (e.g. through which loglines) can DevOps detect unexpected situations that require escalation to human ?
- What are the resource limitations (CPU, RAM) expected for this service ?
- Are all relevant variables documented and adjustable via environment variables (i.e. no hardcoded magic numbers) ?
-->


Linked issue #26: Pipeline creation
**User story**

As a **developer** I want to be able to create arbitrary pipelines out of computational services in the frontend. Mockup services can be used for finding compatible services but the metadata for input/output data structures need to be defined such that they can be used for the MVP.

**Definition of Done**

- [x] Services can interactively added to the pipeline
- [x] Only compatible services can be attached to each other
- [x] Define data structure needed for this (compatible with the pipeline that is being use in the comp. backend)
- [x] Multiple in- and outputs can exist (ports)
- [x] Settings can be defined by input
- [x] Branching
Linked issue comments: user: login: drniiken
created_at: 2018-05-02T17:30:43Z
body: * datastandards
* jason.ad
* decided for
author_association: MEMBER
user: login: sanderegg
created_at: 2018-06-06T11:49:33Z
body: all settings are inputs, with a default
- [ ] the link shall be deletable
- [ ] the settings is read-only when a node is connected
- [ ] all settings visible in the workbench view
author_association: MEMBER

Diff:
diff --git a/api/specs/web-server/_storage.py b/api/specs/web-server/_storage.py
index 56a175d75521..893a25169cb0 100644
--- a/api/specs/web-server/_storage.py
+++ b/api/specs/web-server/_storage.py
@@ -22,6 +22,7 @@
     PresignedLink,
 )
 from models_library.api_schemas_webserver.storage import (
+    BatchDeletePathsBodyParams,
     DataExportPost,
     ListPathsQueryParams,
     StorageLocationPathParams,
@@ -80,6 +81,19 @@ async def compute_path_size(_path: Annotated[StoragePathComputeSizeParams, Depen
     """Compute the size of a path"""
 
 
+@router.post(
+    "/storage/locations/{location_id}/-/paths:batchDelete",
+    response_model=Envelope[TaskGet],
+    status_code=status.HTTP_202_ACCEPTED,
+    description="Deletes Paths",
+)
+async def batch_delete_paths(
+    _path: Annotated[StorageLocationPathParams, Depends()],
+    _body: Annotated[BatchDeletePathsBodyParams, Depends()],
+):
+    """deletes files/folders if user has the rights to"""
+
+
 @router.get(
     "/storage/locations/{location_id}/datasets",
     response_model=Envelope[list[DatasetMetaData]],
diff --git a/packages/models-library/src/models_library/api_schemas_webserver/storage.py b/packages/models-library/src/models_library/api_schemas_webserver/storage.py
index 3049bf4d0bdc..a867a872312d 100644
--- a/packages/models-library/src/models_library/api_schemas_webserver/storage.py
+++ b/packages/models-library/src/models_library/api_schemas_webserver/storage.py
@@ -36,6 +36,10 @@ class ListPathsQueryParams(InputSchema, CursorQueryParameters):
     ] = DEFAULT_NUMBER_OF_PATHS_PER_PAGE
 
 
+class BatchDeletePathsBodyParams(InputSchema):
+    paths: set[Path]
+
+
 class DataExportPost(InputSchema):
     paths: list[StorageFileID]
 
diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/storage/paths.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/storage/paths.py
index 0d03e83a1f6b..c1049bfc1bbb 100644
--- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/storage/paths.py
+++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/storage/paths.py
@@ -32,3 +32,23 @@ async def compute_path_size(
         path=path,
     )
     return async_job_rpc_get, job_id_data
+
+
+async def delete_paths(
+    client: RabbitMQRPCClient,
+    *,
+    user_id: UserID,
+    product_name: ProductName,
+    location_id: LocationID,
+    paths: set[Path],
+) -> tuple[AsyncJobGet, AsyncJobNameData]:
+    job_id_data = AsyncJobNameData(user_id=user_id, product_name=product_name)
+    async_job_rpc_get = await submit(
+        rabbitmq_rpc_client=client,
+        rpc_namespace=STORAGE_RPC_NAMESPACE,
+        method_name=RPCMethodName("delete_paths"),
+        job_id_data=job_id_data,
+        location_id=location_id,
+        paths=paths,
+    )
+    return async_job_rpc_get, job_id_data
diff --git a/services/storage/src/simcore_service_storage/api/_worker_tasks/_paths.py b/services/storage/src/simcore_service_storage/api/_worker_tasks/_paths.py
index fae0bdc770c8..2f3d05da5471 100644
--- a/services/storage/src/simcore_service_storage/api/_worker_tasks/_paths.py
+++ b/services/storage/src/simcore_service_storage/api/_worker_tasks/_paths.py
@@ -2,11 +2,13 @@
 from pathlib import Path
 
 from celery import Task  # type: ignore[import-untyped]
-from models_library.projects_nodes_io import LocationID
+from models_library.projects_nodes_io import LocationID, StorageFileID
 from models_library.users import UserID
-from pydantic import ByteSize
+from pydantic import ByteSize, TypeAdapter
 from servicelib.logging_utils import log_context
+from servicelib.utils import limited_gather
 
+from ...constants import MAX_CONCURRENT_S3_TASKS
 from ...dsm import get_dsm_provider
 from ...modules.celery.models import TaskId
 from ...modules.celery.utils import get_fastapi_app
@@ -25,3 +27,26 @@ async def compute_path_size(
     ):
         dsm = get_dsm_provider(get_fastapi_app(task.app)).get(location_id)
         return await dsm.compute_path_size(user_id, path=Path(path))
+
+
+async def delete_paths(
+    task: Task,
+    task_id: TaskId,
+    user_id: UserID,
+    location_id: LocationID,
+    paths: set[Path],
+) -> None:
+    assert task_id  # nosec
+    with log_context(
+        _logger,
+        logging.INFO,
+        msg=f"delete {paths=} in {location_id=} for {user_id=}",
+    ):
+        dsm = get_dsm_provider(get_fastapi_app(task.app)).get(location_id)
+        files_ids: set[StorageFileID] = {
+            TypeAdapter(StorageFileID).validate_python(f"{path}") for path in paths
+        }
+        await limited_gather(
+            *[dsm.delete_file(user_id, file_id) for file_id in files_ids],
+            limit=MAX_CONCURRENT_S3_TASKS,
+        )
diff --git a/services/storage/src/simcore_service_storage/api/_worker_tasks/tasks.py b/services/storage/src/simcore_service_storage/api/_worker_tasks/tasks.py
index 3848e4114a4c..bf3c4ad9990c 100644
--- a/services/storage/src/simcore_service_storage/api/_worker_tasks/tasks.py
+++ b/services/storage/src/simcore_service_storage/api/_worker_tasks/tasks.py
@@ -7,7 +7,7 @@
 from ...modules.celery._task import define_task
 from ...modules.celery.tasks import export_data
 from ._files import complete_upload_file
-from ._paths import compute_path_size
+from ._paths import compute_path_size, delete_paths
 from ._simcore_s3 import deep_copy_files_from_project
 
 _logger = logging.getLogger(__name__)
@@ -22,5 +22,6 @@ def setup_worker_tasks(app: Celery) -> None:
     ):
         define_task(app, export_data)
         define_task(app, compute_path_size)
+        define_task(app, delete_paths)
         define_task(app, complete_upload_file)
         define_task(app, deep_copy_files_from_project)
diff --git a/services/storage/src/simcore_service_storage/api/rpc/_paths.py b/services/storage/src/simcore_service_storage/api/rpc/_paths.py
index e975191bc57a..0390156dac40 100644
--- a/services/storage/src/simcore_service_storage/api/rpc/_paths.py
+++ b/services/storage/src/simcore_service_storage/api/rpc/_paths.py
@@ -11,6 +11,7 @@
 
 from ...modules.celery import get_celery_client
 from .._worker_tasks._paths import compute_path_size as remote_compute_path_size
+from .._worker_tasks._paths import delete_paths as remote_delete_paths
 
 _logger = logging.getLogger(__name__)
 router = RPCRouter()
@@ -20,7 +21,6 @@
 async def compute_path_size(
     app: FastAPI,
     job_id_data: AsyncJobNameData,
-    # user_id: UserID,
     location_id: LocationID,
     path: Path,
 ) -> AsyncJobGet:
@@ -33,3 +33,20 @@ async def compute_path_size(
     )
 
     return AsyncJobGet(job_id=task_uuid)
+
+
+@router.expose(reraise_if_error_type=None)
+async def delete_paths(
+    app: FastAPI,
+    job_id_data: AsyncJobNameData,
+    location_id: LocationID,
+    paths: set[Path],
+) -> AsyncJobGet:
+    task_uuid = await get_celery_client(app).send_task(
+        remote_delete_paths.__name__,
+        task_context=job_id_data.model_dump(),
+        user_id=job_id_data.user_id,
+        location_id=location_id,
+        paths=paths,
+    )
+    return AsyncJobGet(job_id=task_uuid)
diff --git a/services/storage/src/simcore_service_storage/modules/celery/_celery_types.py b/services/storage/src/simcore_service_storage/modules/celery/_celery_types.py
index 7fb44e087d57..ca8bdf3de8fd 100644
--- a/services/storage/src/simcore_service_storage/modules/celery/_celery_types.py
+++ b/services/storage/src/simcore_service_storage/modules/celery/_celery_types.py
@@ -59,3 +59,4 @@ def register_celery_types() -> None:
     _register_pydantic_types(FileMetaData)
     _register_pydantic_types(FoldersBody)
     _register_pydantic_types(TaskError)
+    register_type(set, _class_full_name(set), encoder=list, decoder=set)
diff --git a/services/storage/tests/unit/test_rpc_handlers_paths.py b/services/storage/tests/unit/test_rpc_handlers_paths.py
index ef345c723e1c..d33bb6bd996d 100644
--- a/services/storage/tests/unit/test_rpc_handlers_paths.py
+++ b/services/storage/tests/unit/test_rpc_handlers_paths.py
@@ -7,7 +7,6 @@
 # pylint:disable=unused-variable
 
 
-import asyncio
 import datetime
 import random
 from pathlib import Path
@@ -31,7 +30,10 @@
 from servicelib.rabbitmq.rpc_interfaces.async_jobs.async_jobs import (
     wait_and_get_result,
 )
-from servicelib.rabbitmq.rpc_interfaces.storage.paths import compute_path_size
+from servicelib.rabbitmq.rpc_interfaces.storage.paths import (
+    compute_path_size,
+    delete_paths,
+)
 from simcore_service_storage.modules.celery.worker import CeleryTaskQueueWorker
 from simcore_service_storage.simcore_s3_dsm import SimcoreS3DataManager
 
@@ -74,7 +76,6 @@ async def _assert_compute_path_size(
         location_id=location_id,
         path=path,
     )
-    await asyncio.sleep(1)
     async for job_composed_result in wait_and_get_result(
         storage_rpc_client,
         rpc_namespace=STORAGE_RPC_NAMESPACE,
@@ -91,6 +92,39 @@ async def _assert_compute_path_size(
             return received_size
 
     pytest.fail("Job did not finish")
+    return ByteSize(0)  # for mypy
+
+
+async def _assert_delete_paths(
+    storage_rpc_client: RabbitMQRPCClient,
+    location_id: LocationID,
+    user_id: UserID,
+    product_name: ProductName,
+    *,
+    paths: set[Path],
+) -> None:
+    async_job, async_job_name = await delete_paths(
+        storage_rpc_client,
+        product_name=product_name,
+        user_id=user_id,
+        location_id=location_id,
+        paths=paths,
+    )
+    async for job_composed_result in wait_and_get_result(
+        storage_rpc_client,
+        rpc_namespace=STORAGE_RPC_NAMESPACE,
+        method_name=RPCMethodName(compute_path_size.__name__),
+        job_id=async_job.job_id,
+        job_id_data=AsyncJobNameData(user_id=user_id, product_name=product_name),
+        client_timeout=datetime.timedelta(seconds=120),
+    ):
+        if job_composed_result.done:
+            response = await job_composed_result.result()
+            assert isinstance(response, AsyncJobResult)
+            assert response.result is None
+            return
+
+    pytest.fail("Job did not finish")
 
 
 @pytest.mark.parametrize(
@@ -246,3 +280,107 @@ async def test_path_compute_size_inexistent_path(
         expected_total_size=0,
         product_name=product_name,
     )
+
+
+@pytest.mark.parametrize(
+    "location_id",
+    [SimcoreS3DataManager.get_location_id()],
+    ids=[SimcoreS3DataManager.get_location_name()],
+    indirect=True,
+)
+async def test_delete_paths_empty_set(
+    initialized_app: FastAPI,
+    storage_rabbitmq_rpc_client: RabbitMQRPCClient,
+    user_id: UserID,
+    location_id: LocationID,
+    product_name: ProductName,
+    with_storage_celery_worker: CeleryTaskQueueWorker,
+):
+    await _assert_delete_paths(
+        storage_rabbitmq_rpc_client,
+        location_id,
+        user_id,
+        product_name,
+        paths=set(),
+    )
+
+
+@pytest.mark.parametrize(
+    "location_id",
+    [SimcoreS3DataManager.get_location_id()],
+    ids=[SimcoreS3DataManager.get_location_name()],
+    indirect=True,
+)
+@pytest.mark.parametrize(
+    "project_params",
+    [
+        ProjectWithFilesParams(
+            num_nodes=1,
+            allowed_file_sizes=(TypeAdapter(ByteSize).validate_python("1b"),),
+            workspace_files_count=15,
+        )
+    ],
+    ids=str,
+)
+async def test_delete_paths(
+    initialized_app: FastAPI,
+    storage_rabbitmq_rpc_client: RabbitMQRPCClient,
+    user_id: UserID,
+    location_id: LocationID,
+    with_random_project_with_files: tuple[
+        dict[str, Any],
+        dict[NodeID, dict[SimcoreS3FileID, FileIDDict]],
+    ],
+    project_params: ProjectWithFilesParams,
+    product_name: ProductName,
+    with_storage_celery_worker: CeleryTaskQueueWorker,
+):
+    assert (
+        len(project_params.allowed_file_sizes) == 1
+    ), "test preconditions are not filled! allowed file sizes should have only 1 option for this test"
+    project, list_of_files = with_random_project_with_files
+
+    total_num_files = sum(
+        len(files_in_node) for files_in_node in list_of_files.values()
+    )
+
+    # get size of a full project
+    expected_total_size = project_params.allowed_file_sizes[0] * total_num_files
+    path = Path(project["uuid"])
+    await _assert_compute_path_size(
+        storage_rabbitmq_rpc_client,
+        location_id,
+        user_id,
+        path=path,
+        expected_total_size=expected_total_size,
+        product_name=product_name,
+    )
+
+    # now select multiple random files to delete
+    selected_paths = random.sample(
+        list(
+            list_of_files[
+                NodeID(random.choice(list(project["workbench"])))  # noqa: S311
+            ]
+        ),
+        round(project_params.workspace_files_count / 2),
+    )
+
+    await _assert_delete_paths(
+        storage_rabbitmq_rpc_client,
+        location_id,
+        user_id,
+        product_name,
+        paths=set({Path(_) for _ in selected_paths}),
+    )
+
+    # the size is reduced by the amount of deleted files
+    await _assert_compute_path_size(
+        storage_rabbitmq_rpc_client,
+        location_id,
+        user_id,
+        path=path,
+        expected_total_size=expected_total_size
+        - len(selected_paths) * project_params.allowed_file_sizes[0],
+        product_name=product_name,
+    )
diff --git a/services/web/server/VERSION b/services/web/server/VERSION
index bf54d53ec26d..62545fa777a8 100644
--- a/services/web/server/VERSION
+++ b/services/web/server/VERSION
@@ -1 +1 @@
-0.61.4
+0.61.5
diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg
index ccbfa6b24c9e..ae7dc66a0ee0 100644
--- a/services/web/server/setup.cfg
+++ b/services/web/server/setup.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 0.61.4
+current_version = 0.61.5
 commit = True
 message = services/webserver api version: {current_version} → {new_version}
 tag = False
diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml
index 6facdd9ddf15..ca947f2f17d3 100644
--- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml
+++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml
@@ -2,7 +2,7 @@ openapi: 3.1.0
 info:
   title: simcore-service-webserver
   description: Main service with an interface (http-API & websockets) to the web front-end
-  version: 0.61.4
+  version: 0.61.5
 servers:
 - url: ''
   description: webserver
@@ -6168,6 +6168,38 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/Envelope_TaskGet_'
+  /v0/storage/locations/{location_id}/-/paths:batchDelete:
+    post:
+      tags:
+      - storage
+      summary: Batch Delete Paths
+      description: Deletes Paths
+      operationId: batch_delete_paths
+      parameters:
+      - name: location_id
+        in: path
+        required: true
+        schema:
+          type: integer
+          title: Location Id
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: array
+              uniqueItems: true
+              items:
+                type: string
+                format: path
+              title: Paths
+      responses:
+        '202':
+          description: Successful Response
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Envelope_TaskGet_'
   /v0/storage/locations/{location_id}/datasets:
     get:
       tags:
diff --git a/services/web/server/src/simcore_service_webserver/storage/_rest.py b/services/web/server/src/simcore_service_webserver/storage/_rest.py
index fbc419d90153..32729e0923b0 100644
--- a/services/web/server/src/simcore_service_webserver/storage/_rest.py
+++ b/services/web/server/src/simcore_service_webserver/storage/_rest.py
@@ -13,6 +13,7 @@
     TaskGet,
 )
 from models_library.api_schemas_rpc_async_jobs.async_jobs import (
+    AsyncJobGet,
     AsyncJobNameData,
 )
 from models_library.api_schemas_storage.storage_schemas import (
@@ -22,7 +23,9 @@
     LinkType,
 )
 from models_library.api_schemas_webserver.storage import (
+    BatchDeletePathsBodyParams,
     DataExportPost,
+    StorageLocationPathParams,
     StoragePathComputeSizeParams,
 )
 from models_library.projects_nodes_io import LocationID
@@ -42,6 +45,9 @@
 from servicelib.rabbitmq.rpc_interfaces.storage.paths import (
     compute_path_size as remote_compute_path_size,
 )
+from servicelib.rabbitmq.rpc_interfaces.storage.paths import (
+    delete_paths as remote_delete_paths,
+)
 from servicelib.request_keys import RQT_USERID_KEY
 from servicelib.rest_responses import unwrap_envelope
 from yarl import URL
@@ -172,6 +178,23 @@ async def list_paths(request: web.Request) -> web.Response:
     return create_data_response(payload, status=resp_status)
 
 
+def _create_data_response_from_async_job(
+    request: web.Request,
+    async_job: AsyncJobGet,
+) -> web.Response:
+    async_job_id = f"{async_job.job_id}"
+    return create_data_response(
+        TaskGet(
+            task_id=async_job_id,
+            task_name=async_job_id,
+            status_href=f"{request.url.with_path(str(request.app.router['get_async_job_status'].url_for(task_id=async_job_id)))}",
+            abort_href=f"{request.url.with_path(str(request.app.router['abort_async_job'].url_for(task_id=async_job_id)))}",
+            result_href=f"{request.url.with_path(str(request.app.router['get_async_job_result'].url_for(task_id=async_job_id)))}",
+        ),
+        status=status.HTTP_202_ACCEPTED,
+    )
+
+
 @routes.post(
     f"{_storage_locations_prefix}/{{location_id}}/paths/{{path}}:size",
     name="compute_path_size",
@@ -193,17 +216,29 @@ async def compute_path_size(request: web.Request) -> web.Response:
         path=path_params.path,
     )
 
-    _job_id = f"{async_job.job_id}"
-    return create_data_response(
-        TaskGet(
-            task_id=_job_id,
-            task_name=_job_id,
-            status_href=f"{request.url.with_path(str(request.app.router['get_async_job_status'].url_for(task_id=_job_id)))}",
-            abort_href=f"{request.url.with_path(str(request.app.router['abort_async_job'].url_for(task_id=_job_id)))}",
-            result_href=f"{request.url.with_path(str(request.app.router['get_async_job_result'].url_for(task_id=_job_id)))}",
-        ),
-        status=status.HTTP_202_ACCEPTED,
+    return _create_data_response_from_async_job(request, async_job)
+
+
+@routes.post(
+    f"{_storage_locations_prefix}/{{location_id}}/-/paths:batchDelete",
+    name="batch_delete_paths",
+)
+@login_required
+@permission_required("storage.files.*")
+async def batch_delete_paths(request: web.Request):
+    req_ctx = RequestContext.model_validate(request)
+    path_params = parse_request_path_parameters_as(StorageLocationPathParams, request)
+    body = await parse_request_body_as(BatchDeletePathsBodyParams, request)
+
+    rabbitmq_rpc_client = get_rabbitmq_rpc_client(request.app)
+    async_job, _ = await remote_delete_paths(
+        rabbitmq_rpc_client,
+        user_id=req_ctx.user_id,
+        product_name=req_ctx.product_name,
+        location_id=path_params.location_id,
+        paths=body.paths,
     )
+    return _create_data_response_from_async_job(request, async_job)
 
 
 @routes.get(
diff --git a/services/web/server/tests/unit/with_dbs/01/storage/test_storage.py b/services/web/server/tests/unit/with_dbs/01/storage/test_storage.py
index 3ea1ec402306..2a447af81e34 100644
--- a/services/web/server/tests/unit/with_dbs/01/storage/test_storage.py
+++ b/services/web/server/tests/unit/with_dbs/01/storage/test_storage.py
@@ -44,6 +44,7 @@
 )
 from models_library.api_schemas_webserver._base import OutputSchema
 from models_library.api_schemas_webserver.storage import (
+    BatchDeletePathsBodyParams,
     DataExportPost,
 )
 from models_library.generics import Envelope
@@ -190,6 +191,51 @@ async def test_compute_path_size(
         TypeAdapter(TaskGet).validate_python(data)
 
 
+@pytest.mark.parametrize(
+    "user_role,expected",
+    [
+        (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
+        (UserRole.GUEST, status.HTTP_202_ACCEPTED),
+        (UserRole.USER, status.HTTP_202_ACCEPTED),
+        (UserRole.TESTER, status.HTTP_202_ACCEPTED),
+    ],
+)
+@pytest.mark.parametrize(
+    "backend_result_or_exception",
+    [
+        AsyncJobGet(job_id=AsyncJobId(f"{_faker.uuid4()}")),
+    ],
+    ids=lambda x: type(x).__name__,
+)
+async def test_batch_delete_paths(
+    client: TestClient,
+    logged_user: dict[str, Any],
+    expected: int,
+    location_id: LocationID,
+    faker: Faker,
+    create_storage_paths_rpc_client_mock: Callable[[str, Any], None],
+    backend_result_or_exception: Any,
+):
+    create_storage_paths_rpc_client_mock(
+        submit.__name__,
+        backend_result_or_exception,
+    )
+
+    body = BatchDeletePathsBodyParams(
+        paths={Path(f"{faker.file_path(absolute=False)}")}
+    )
+
+    assert client.app
+    url = client.app.router["batch_delete_paths"].url_for(
+        location_id=f"{location_id}",
+    )
+
+    resp = await client.post(f"{url}", json=body.model_dump(mode="json"))
+    data, error = await assert_status(resp, expected)
+    if not error:
+        TypeAdapter(TaskGet).validate_python(data)
+
+
 @pytest.mark.parametrize(
     "user_role,expected",
     [


Reviews:
user: login: odeimaiz
body: LRT 💪 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-31T15:25:49Z
user: login: matusdrobuliak66
body: Thanks!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-01T06:06:36Z
user: login: GitHK
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T08:18:55Z
user: login: pcrespov
body: very nice! thx
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-01T08:45:04Z
user: login: sanderegg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T09:51:58Z

Review comments:
diff_hunk: @@ -93,6 +96,39 @@ async def _assert_compute_path_size(
     pytest.fail("Job did not finish")
 
 
+async def _assert_delete_paths(
+    storage_rpc_client: RabbitMQRPCClient,
+    location_id: LocationID,
+    user_id: UserID,
+    product_name: ProductName,
+    *,
+    paths: set[Path],
+) -> None:
+    async_job, async_job_name = await delete_paths(
+        storage_rpc_client,
+        product_name=product_name,
+        user_id=user_id,
+        location_id=location_id,
+        paths=paths,
+    )
+    await asyncio.sleep(1)
path: services/storage/tests/unit/test_rpc_handlers_paths.py
user: login: GitHK
body: please use tenacity here, instead of having a fixed sleep, thee always end up being flaky
created_at: 2025-04-01T08:17:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -190,6 +191,51 @@ async def test_compute_path_size(
         TypeAdapter(TaskGet).validate_python(data)
 
 
+@pytest.mark.parametrize(
+    "user_role,expected",
+    [
+        (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
+        (UserRole.GUEST, status.HTTP_202_ACCEPTED),
+        (UserRole.USER, status.HTTP_202_ACCEPTED),
+        (UserRole.TESTER, status.HTTP_202_ACCEPTED),
+    ],
+)
+@pytest.mark.parametrize(
+    "backend_result_or_exception",
+    [
+        AsyncJobGet(job_id=AsyncJobId(f"{_faker.uuid4()}")),
+    ],
+    ids=lambda x: type(x).__name__,
+)
+async def test_batch_delete_paths(
+    client: TestClient,
+    logged_user: dict[str, Any],
+    expected: int,
+    location_id: LocationID,
+    faker: Faker,
+    create_storage_paths_rpc_client_mock: Callable[[str, Any], None],
+    backend_result_or_exception: Any,
+):
+    create_storage_paths_rpc_client_mock(
path: services/web/server/tests/unit/with_dbs/01/storage/test_storage.py
user: login: pcrespov
body: THOUGHT: These might be very useful to test the api-server in the future. 
As an idea, checkout what I started in packages/pytest-simcore/src/pytest_simcore/helpers/catalog_rpc_server.py for the rpc funtions in catalog . I use that in both the api-server and the web-server. perhaps we can create a systematic way of having mockups for rpc clients


created_at: 2025-04-01T08:44:55Z
author_association: MEMBER
diff_hunk: @@ -93,6 +96,39 @@ async def _assert_compute_path_size(
     pytest.fail("Job did not finish")
 
 
+async def _assert_delete_paths(
+    storage_rpc_client: RabbitMQRPCClient,
+    location_id: LocationID,
+    user_id: UserID,
+    product_name: ProductName,
+    *,
+    paths: set[Path],
+) -> None:
+    async_job, async_job_name = await delete_paths(
+        storage_rpc_client,
+        product_name=product_name,
+        user_id=user_id,
+        location_id=location_id,
+        paths=paths,
+    )
+    await asyncio.sleep(1)
path: services/storage/tests/unit/test_rpc_handlers_paths.py
user: login: sanderegg
body: I will try to remove it. but I think that is a "feature" of our implementation.
created_at: 2025-04-01T09:51:57Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-03-31T06:59:03Z
body: ## [Codecov](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) Report
Attention: Patch coverage is `89.74359%` with `4 lines` in your changes missing coverage. Please review.
> Project coverage is 87.25%. Comparing base [(`bc543e5`)](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/commit/bc543e542f5533e084ed16dad06251648471020b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) to head [(`9433757`)](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/commit/9433757aba7fbc48fb59092f53f38c76ae1e1a51?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation).
> Report is 1 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #7450      +/-   ##
==========================================
- Coverage   87.40%   87.25%   -0.15%     
==========================================
  Files        1729     1563     -166     
  Lines       66830    63071    -3759     
  Branches     1133      899     -234     
==========================================
- Hits        58410    55033    -3377     
+ Misses       8099     7778     -321     
+ Partials      321      260      -61     
```

| [Flag](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | Coverage Δ | |
|---|---|---|
| [integrationtests](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `65.18% <40.00%> (+<0.01%)` | :arrow_up: |
| [unittests](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `86.37% <89.74%> (-0.20%)` | :arrow_down: |

| [Components](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=components&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | Coverage Δ | |
|---|---|---|
| [api](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `76.84% <ø> (ø)` | |
| [pkg_aws_library](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `∅ <ø> (∅)` | |
| [pkg_dask_task_models_library](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `∅ <ø> (∅)` | |
| [pkg_models_library](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `92.05% <100.00%> (+<0.01%)` | :arrow_up: |
| [pkg_notifications_library](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `∅ <ø> (∅)` | |
| [pkg_postgres_database](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `∅ <ø> (∅)` | |
| [pkg_service_integration](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `70.03% <ø> (ø)` | |
| [pkg_service_library](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `72.42% <0.00%> (-0.06%)` | :arrow_down: |
| [pkg_settings_library](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `∅ <ø> (∅)` | |
| [pkg_simcore_sdk](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `85.46% <ø> (ø)` | |
| [agent](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `96.46% <ø> (ø)` | |
| [api_server](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `90.73% <ø> (ø)` | |
| [autoscaling](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `96.08% <ø> (ø)` | |
| [catalog](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `91.82% <ø> (ø)` | |
| [clusters_keeper](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `99.24% <ø> (ø)` | |
| [dask_sidecar](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `91.25% <ø> (ø)` | |
| [datcore_adapter](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `98.11% <ø> (ø)` | |
| [director](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `76.96% <ø> (+0.09%)` | :arrow_up: |
| [director_v2](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `91.30% <ø> (ø)` | |
| [dynamic_scheduler](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `97.35% <ø> (ø)` | |
| [dynamic_sidecar](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `90.11% <ø> (ø)` | |
| [efs_guardian](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `89.79% <ø> (ø)` | |
| [invitations](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `93.28% <ø> (ø)` | |
| [payments](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `92.66% <ø> (ø)` | |
| [resource_usage_tracker](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `88.85% <ø> (-0.28%)` | :arrow_down: |
| [storage](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `86.83% <100.00%> (+0.18%)` | :arrow_up: |
| [webclient](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `∅ <ø> (∅)` | |
| [webserver](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation) | `85.91% <100.00%> (+0.06%)` | :arrow_up: |

------

[Continue to review full report in Codecov by Sentry](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450?dropdown=coverage&src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation). Last update [bc543e5...9433757](https://app.codecov.io/gh/ITISFoundation/osparc-simcore/pull/7450?dropdown=coverage&src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ITISFoundation).

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-01T09:54:14Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=ITISFoundation_osparc-simcore&pullRequest=7450) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=ITISFoundation_osparc-simcore&pullRequest=7450&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=ITISFoundation_osparc-simcore&pullRequest=7450&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=ITISFoundation_osparc-simcore&pullRequest=7450&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=ITISFoundation_osparc-simcore&pullRequest=7450&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=ITISFoundation_osparc-simcore&pullRequest=7450&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=ITISFoundation_osparc-simcore&pullRequest=7450)


author_association: NONE
user: login: sanderegg
created_at: 2025-04-01T11:37:18Z
body: @mergify queue
author_association: MEMBER
user: login: mergify[bot]
created_at: 2025-04-01T11:37:27Z
body: > queue

#### ✅ The pull request has been merged automatically

<details>

The pull request has been merged automatically at *5f1c5d75ac2533991562f0837152a1b875a6c3d1*

</details>


<!---
DO NOT EDIT
-*- Mergify Payload -*-
{"command": "queue", "conclusion": "success", "action_is_running": true}
-*- Mergify Payload End -*-
-->
author_association: CONTRIBUTOR