|
1 | 1 | import logging |
2 | 2 | import re |
3 | 3 | import urllib.parse |
| 4 | +from datetime import timedelta |
4 | 5 | from functools import partial |
5 | 6 | from mimetypes import guess_type |
6 | | -from typing import Literal |
| 7 | +from typing import Final, Literal |
7 | 8 | from uuid import UUID |
8 | 9 |
|
9 | 10 | from fastapi import FastAPI |
10 | 11 | from fastapi.encoders import jsonable_encoder |
| 12 | +from httpx import QueryParams |
11 | 13 | from models_library.api_schemas_storage.storage_schemas import ( |
| 14 | + ETag, |
12 | 15 | FileMetaDataArray, |
13 | 16 | ) |
14 | 17 | from models_library.api_schemas_storage.storage_schemas import ( |
15 | 18 | FileMetaDataGet as StorageFileMetaData, |
16 | 19 | ) |
17 | 20 | from models_library.api_schemas_storage.storage_schemas import ( |
| 21 | + FileUploadCompleteFutureResponse, |
| 22 | + FileUploadCompleteResponse, |
| 23 | + FileUploadCompleteState, |
| 24 | + FileUploadCompletionBody, |
18 | 25 | FileUploadSchema, |
| 26 | + LinkType, |
19 | 27 | PresignedLink, |
| 28 | + UploadedPart, |
20 | 29 | ) |
21 | 30 | from models_library.basic_types import SHA256Str |
22 | 31 | from models_library.generics import Envelope |
23 | 32 | from models_library.rest_pagination import PageLimitInt, PageOffsetInt |
24 | 33 | from pydantic import AnyUrl |
25 | 34 | from settings_library.tracing import TracingSettings |
26 | | -from starlette.datastructures import URL |
| 35 | +from simcore_service_api_server.exceptions.backend_errors import BackendTimeoutError |
| 36 | +from simcore_service_api_server.models.schemas.files import UserFile |
| 37 | +from simcore_service_api_server.models.schemas.jobs import UserFileToProgramJob |
| 38 | +from tenacity import ( |
| 39 | + AsyncRetrying, |
| 40 | + TryAgain, |
| 41 | + before_sleep_log, |
| 42 | + retry_if_exception_type, |
| 43 | + stop_after_delay, |
| 44 | + wait_fixed, |
| 45 | +) |
27 | 46 |
|
28 | 47 | from ..core.settings import StorageSettings |
29 | 48 | from ..exceptions.service_errors_utils import service_exception_mapper |
30 | 49 | from ..models.domain.files import File |
31 | 50 | from ..utils.client_base import BaseServiceClientApi, setup_client_instance |
32 | 51 |
|
| 52 | +_POLL_TIMEOUT: Final[timedelta] = timedelta(minutes=10) |
| 53 | + |
| 54 | + |
33 | 55 | _logger = logging.getLogger(__name__) |
34 | 56 |
|
35 | 57 | _exception_mapper = partial(service_exception_mapper, service_name="Storage") |
@@ -157,41 +179,81 @@ async def delete_file(self, *, user_id: int, quoted_storage_file_id: str) -> Non |
157 | 179 | response.raise_for_status() |
158 | 180 |
|
159 | 181 | @_exception_mapper(http_status_map={}) |
160 | | - async def get_upload_links( |
161 | | - self, *, user_id: int, file_id: UUID, file_name: str |
| 182 | + async def get_file_upload_links( |
| 183 | + self, *, user_id: int, file: File, client_file: UserFileToProgramJob | UserFile |
162 | 184 | ) -> FileUploadSchema: |
163 | | - object_path = urllib.parse.quote_plus(f"api/{file_id}/{file_name}") |
| 185 | + |
| 186 | + query_params = QueryParams( |
| 187 | + user_id=f"{user_id}", |
| 188 | + link_type=LinkType.PRESIGNED.value, |
| 189 | + file_size=int(client_file.filesize), |
| 190 | + is_directory="false", |
| 191 | + sha256_checksum=f"{client_file.sha256_checksum}", |
| 192 | + ) |
164 | 193 |
|
165 | 194 | # complete_upload_file |
166 | 195 | response = await self.client.put( |
167 | | - f"/locations/{self.SIMCORE_S3_ID}/files/{object_path}", |
168 | | - params={"user_id": user_id, "file_size": 0}, |
| 196 | + f"/locations/{self.SIMCORE_S3_ID}/files/{file.quoted_storage_file_id}", |
| 197 | + params=query_params, |
169 | 198 | ) |
170 | 199 | response.raise_for_status() |
171 | 200 |
|
172 | 201 | enveloped_data = Envelope[FileUploadSchema].model_validate_json(response.text) |
173 | 202 | assert enveloped_data.data # nosec |
174 | 203 | return enveloped_data.data |
175 | 204 |
|
176 | | - async def create_complete_upload_link( |
177 | | - self, *, file: File, query: dict[str, str] | None = None |
178 | | - ) -> URL: |
179 | | - url = URL( |
180 | | - f"{self.client.base_url}locations/{self.SIMCORE_S3_ID}/files/{file.quoted_storage_file_id}:complete" |
| 205 | + @_exception_mapper(http_status_map={}) |
| 206 | + async def complete_file_upload( |
| 207 | + self, *, user_id: int, file: File, uploaded_parts: list[UploadedPart] |
| 208 | + ) -> ETag: |
| 209 | + |
| 210 | + response = await self.client.post( |
| 211 | + f"/locations/{self.SIMCORE_S3_ID}/files/{file.storage_file_id}:complete", |
| 212 | + params={"user_id": f"{user_id}"}, |
| 213 | + json=jsonable_encoder(FileUploadCompletionBody(parts=uploaded_parts)), |
181 | 214 | ) |
182 | | - if query is not None: |
183 | | - url = url.include_query_params(**query) |
184 | | - return url |
185 | | - |
186 | | - async def create_abort_upload_link( |
187 | | - self, *, file: File, query: dict[str, str] | None = None |
188 | | - ) -> URL: |
189 | | - url = URL( |
190 | | - f"{self.client.base_url}locations/{self.SIMCORE_S3_ID}/files/{file.quoted_storage_file_id}:abort" |
| 215 | + response.raise_for_status() |
| 216 | + file_upload_complete_response = Envelope[ |
| 217 | + FileUploadCompleteResponse |
| 218 | + ].model_validate_json(response.text) |
| 219 | + assert file_upload_complete_response.data # nosec |
| 220 | + state_url = f"{file_upload_complete_response.data.links.state}" |
| 221 | + try: |
| 222 | + async for attempt in AsyncRetrying( |
| 223 | + reraise=True, |
| 224 | + wait=wait_fixed(1), |
| 225 | + stop=stop_after_delay(_POLL_TIMEOUT), |
| 226 | + retry=retry_if_exception_type(TryAgain), |
| 227 | + before_sleep=before_sleep_log(_logger, logging.DEBUG), |
| 228 | + ): |
| 229 | + with attempt: |
| 230 | + resp = await self.client.post(state_url) |
| 231 | + resp.raise_for_status() |
| 232 | + future_enveloped = Envelope[ |
| 233 | + FileUploadCompleteFutureResponse |
| 234 | + ].model_validate_json(resp.text) |
| 235 | + assert future_enveloped.data # nosec |
| 236 | + if future_enveloped.data.state == FileUploadCompleteState.NOK: |
| 237 | + raise TryAgain() |
| 238 | + |
| 239 | + assert future_enveloped.data.e_tag # nosec |
| 240 | + _logger.debug( |
| 241 | + "multipart upload completed in %s, received %s", |
| 242 | + attempt.retry_state.retry_object.statistics, |
| 243 | + f"{future_enveloped.data.e_tag=}", |
| 244 | + ) |
| 245 | + return future_enveloped.data.e_tag |
| 246 | + except TryAgain as exc: |
| 247 | + raise BackendTimeoutError() from exc |
| 248 | + raise BackendTimeoutError() |
| 249 | + |
| 250 | + @_exception_mapper(http_status_map={}) |
| 251 | + async def abort_file_upload(self, *, user_id: int, file: File) -> None: |
| 252 | + response = await self.client.post( |
| 253 | + f"/locations/{self.SIMCORE_S3_ID}/files/{file.quoted_storage_file_id}:abort", |
| 254 | + params={"user_id": f"{user_id}"}, |
191 | 255 | ) |
192 | | - if query is not None: |
193 | | - url = url.include_query_params(**query) |
194 | | - return url |
| 256 | + response.raise_for_status() |
195 | 257 |
|
196 | 258 | @_exception_mapper(http_status_map={}) |
197 | 259 | async def create_soft_link( |
|
0 commit comments