Skip to content

Commit d5d6617

Browse files
committed
cesnet invenio multipart
1 parent 22c1e0e commit d5d6617

3 files changed

Lines changed: 351 additions & 2 deletions

File tree

lib/galaxy/files/sources/_rdm.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,15 @@
2525
class RDMFileSourceTemplateConfiguration(BaseFileSourceTemplateConfiguration):
2626
token: Optional[Union[str, TemplateExpansion]] = None
2727
public_name: Optional[Union[str, TemplateExpansion]] = None
28+
multipart_threshold: Optional[Union[int, TemplateExpansion]] = None # bytes
29+
multipart_chunk_size: Optional[Union[int, TemplateExpansion]] = None # bytes
2830

2931

3032
class RDMFileSourceConfiguration(BaseFileSourceConfiguration):
3133
token: Optional[str] = None
3234
public_name: Optional[str] = None
35+
multipart_threshold: Optional[int] = None # bytes
36+
multipart_chunk_size: Optional[int] = None # bytes
3337

3438

3539
class ContainerAndFileIdentifier(NamedTuple):

lib/galaxy/files/sources/invenio.py

Lines changed: 229 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import datetime
22
import json
3+
import logging
4+
import math
5+
import os
36
import re
47
import urllib.request
8+
from concurrent.futures import (
9+
as_completed,
10+
ThreadPoolExecutor,
11+
)
512
from typing import (
613
Any,
714
cast,
@@ -10,6 +17,8 @@
1017
from urllib.error import HTTPError
1118
from urllib.parse import quote
1219

20+
log = logging.getLogger(__name__)
21+
1322
from typing_extensions import (
1423
Literal,
1524
TypedDict,
@@ -104,6 +113,56 @@ class RecordLinks(TypedDict):
104113
reserve_doi: str
105114

106115

116+
# AWS S3 multipart limits (used by Invenio RDM)
117+
MIN_UPLOAD_PART_SIZE = 50 * 1024 * 1024 # 50 MiB
118+
MAX_UPLOAD_PART_SIZE = 5 * 1024**3 # 5 GiB
119+
MAX_UPLOAD_PARTS = 10_000
120+
121+
# Default threshold for using multipart upload (100 MiB)
122+
DEFAULT_MULTIPART_THRESHOLD = 100 * 1024 * 1024
123+
124+
125+
def calculate_multipart_params(file_size: int, preferred_part_size: int | None = None) -> tuple[int, int]:
126+
"""Calculate optimal parts count and part size for multipart upload.
127+
128+
Args:
129+
file_size: Total file size in bytes
130+
preferred_part_size: Preferred part size in bytes (optional)
131+
132+
Returns:
133+
Tuple of (parts_count, part_size)
134+
135+
Note:
136+
Maximum uploadable file size is MAX_UPLOAD_PARTS * MAX_UPLOAD_PART_SIZE (~48.8 TiB).
137+
Files larger than this will still return valid params but would fail server-side.
138+
"""
139+
if file_size == 0:
140+
return 1, 0
141+
142+
# Start with preferred or minimum part size
143+
part_size = preferred_part_size or MIN_UPLOAD_PART_SIZE
144+
145+
# Ensure part_size is within bounds
146+
part_size = max(part_size, MIN_UPLOAD_PART_SIZE)
147+
part_size = min(part_size, MAX_UPLOAD_PART_SIZE)
148+
149+
# Calculate parts needed
150+
parts = math.ceil(file_size / part_size)
151+
152+
# If too many parts, increase part size (up to max)
153+
while parts > MAX_UPLOAD_PARTS and part_size < MAX_UPLOAD_PART_SIZE:
154+
part_size = min(part_size * 2, MAX_UPLOAD_PART_SIZE)
155+
parts = math.ceil(file_size / part_size)
156+
157+
# For extremely large files, cap parts at MAX_UPLOAD_PARTS
158+
# This means part_size may effectively be larger than calculated
159+
# but such files would likely fail server-side anyway
160+
if parts > MAX_UPLOAD_PARTS:
161+
parts = MAX_UPLOAD_PARTS
162+
163+
return parts, part_size
164+
165+
107166
class InvenioRecord(TypedDict):
108167
id: str
109168
title: str
@@ -310,7 +369,7 @@ def create_draft_file_container(
310369
"metadata": {
311370
"title": title,
312371
"publication_date": today,
313-
"resource_type": {"id": "dataset"},
372+
"resource_type": {"id": "c_393c"},
314373
"creators": [
315374
creator,
316375
],
@@ -331,6 +390,33 @@ def upload_file_to_draft_container(
331390
file_path: str,
332391
context: FilesSourceRuntimeContext[RDMFileSourceConfiguration],
333392
):
393+
file_size = os.path.getsize(file_path)
394+
threshold = context.config.multipart_threshold
395+
396+
# Use default threshold if not configured
397+
if threshold is None or threshold <= 0:
398+
threshold = DEFAULT_MULTIPART_THRESHOLD
399+
400+
use_multipart = file_size >= threshold
401+
402+
if use_multipart:
403+
log.info(f"Using multipart upload for file '{filename}' ({file_size} bytes >= threshold {threshold})")
404+
self._upload_file_multipart(record_id, filename, file_path, file_size, context)
405+
else:
406+
self._upload_file_single(record_id, filename, file_path, context, file_size)
407+
408+
def _upload_file_single(
409+
self,
410+
record_id: str,
411+
filename: str,
412+
file_path: str,
413+
context: FilesSourceRuntimeContext[RDMFileSourceConfiguration],
414+
file_size: Optional[int] = None,
415+
):
416+
"""Upload a file using single PUT request."""
417+
if file_size is None:
418+
file_size = os.path.getsize(file_path)
419+
334420
record = self._get_draft_record(record_id, context)
335421
upload_file_url = record["links"]["files"]
336422
headers = self._get_request_headers(context, auth_required=True)
@@ -346,12 +432,148 @@ def upload_file_to_draft_container(
346432
commit_file_upload_url = file_entry["links"]["commit"]
347433
with open(file_path, "rb") as file:
348434
response = requests.put(upload_file_content_url, data=file, headers=headers)
435+
# Handle 413 (Payload Too Large) - suggest using multipart upload
436+
if response.status_code == 413:
437+
threshold_mb = DEFAULT_MULTIPART_THRESHOLD / (1024 * 1024)
438+
raise Exception(
439+
f"Failed to upload file '{filename}' ({file_size} bytes): HTTP 413 Payload Too Large. "
440+
f"The server rejected the upload because the file is too large for a single request. "
441+
f"Please configure 'multipart_threshold' to {threshold_mb}MB or lower to enable multipart upload for files of this size."
442+
)
349443
self._ensure_response_has_expected_status_code(response, 200)
350444

351445
# Commit file upload
352446
response = requests.post(commit_file_upload_url, headers=headers)
353447
self._ensure_response_has_expected_status_code(response, 200)
354448

449+
def _upload_file_multipart(
450+
self,
451+
record_id: str,
452+
filename: str,
453+
file_path: str,
454+
file_size: int,
455+
context: FilesSourceRuntimeContext[RDMFileSourceConfiguration],
456+
):
457+
"""Upload a file using multipart upload.
458+
459+
Flow:
460+
1. Calculate parts/part_size
461+
2. POST with transfer metadata
462+
3. Server returns links.parts[] with URL for each part
463+
4. Upload parts (parallel for > 2 parts)
464+
5. POST to commit URL
465+
"""
466+
preferred_part_size = context.config.multipart_chunk_size
467+
num_parts, part_size = calculate_multipart_params(file_size, preferred_part_size)
468+
469+
log.info(f"Multipart upload: {num_parts} parts of {part_size} bytes each for '{filename}'")
470+
471+
record = self._get_draft_record(record_id, context)
472+
upload_file_url = record["links"]["files"]
473+
headers = self._get_request_headers(context, auth_required=True)
474+
475+
# Initialize multipart upload with transfer metadata
476+
file_metadata = {
477+
"key": filename,
478+
"size": file_size,
479+
"transfer": {
480+
"type": "M",
481+
"parts": num_parts,
482+
"part_size": part_size,
483+
},
484+
}
485+
response = requests.post(upload_file_url, json=[file_metadata], headers=headers)
486+
self._ensure_response_has_expected_status_code(response, 201)
487+
488+
# Get part upload URLs from response
489+
entries = response.json()["entries"]
490+
file_entry = next(entry for entry in entries if entry["key"] == filename)
491+
commit_url = file_entry["links"]["commit"]
492+
part_links = file_entry.get("links", {}).get("parts", [])
493+
494+
if len(part_links) != num_parts:
495+
raise Exception(
496+
f"Server returned {len(part_links)} part URLs but expected {num_parts} for file '{filename}'"
497+
)
498+
499+
# Sort part links by part number to ensure correct ordering
500+
# Invenio uses 'part' key, not 'part_number'
501+
part_links = sorted(part_links, key=lambda p: p.get("part", 0))
502+
503+
# Upload parts
504+
self._upload_parts(file_path, file_size, part_size, part_links, headers)
505+
506+
# Commit multipart upload
507+
response = requests.post(commit_url, json={}, headers=headers)
508+
self._ensure_response_has_expected_status_code(response, 200)
509+
log.info(f"Multipart upload completed for '{filename}'")
510+
511+
def _upload_parts(
512+
self,
513+
file_path: str,
514+
file_size: int,
515+
part_size: int,
516+
part_links: list[dict],
517+
headers: dict,
518+
):
519+
"""Upload all parts, sequentially for <=2 parts, parallel otherwise."""
520+
num_parts = len(part_links)
521+
522+
if num_parts <= 2:
523+
for part_index, part_info in enumerate(part_links):
524+
self._upload_single_part(file_path, file_size, part_size, part_index, part_info)
525+
else:
526+
max_workers = min(4, num_parts)
527+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
528+
futures = {}
529+
for part_index, part_info in enumerate(part_links):
530+
future = executor.submit(
531+
self._upload_single_part,
532+
file_path,
533+
file_size,
534+
part_size,
535+
part_index,
536+
part_info,
537+
)
538+
futures[future] = part_index
539+
540+
for future in as_completed(futures):
541+
part_index = futures[future]
542+
try:
543+
future.result()
544+
except Exception as e:
545+
log.error(f"Failed to upload part {part_index}: {e}")
546+
raise
547+
548+
def _upload_single_part(
549+
self,
550+
file_path: str,
551+
file_size: int,
552+
part_size: int,
553+
part_index: int,
554+
part_info: dict,
555+
):
556+
"""Upload a single part of a multipart upload."""
557+
part_url = part_info.get("url")
558+
if not part_url:
559+
raise Exception(f"No URL provided for part {part_index}")
560+
561+
start_byte = part_index * part_size
562+
end_byte = min(start_byte + part_size, file_size)
563+
part_content_length = end_byte - start_byte
564+
565+
log.debug(f"Uploading part {part_index}: bytes {start_byte}-{end_byte-1} ({part_content_length} bytes)")
566+
567+
# Read the entire part into memory and upload
568+
with open(file_path, "rb") as f:
569+
f.seek(start_byte)
570+
part_data = f.read(part_content_length)
571+
572+
# Use empty headers - presigned URLs are authenticated via query parameters
573+
# Adding Authorization or other headers would invalidate the signature
574+
response = requests.put(part_url, data=part_data)
575+
self._ensure_response_has_expected_status_code(response, 200)
576+
355577
def download_file_from_container(
356578
self,
357579
container_id: str,
@@ -544,7 +766,12 @@ def _raise_auth_required(self):
544766
)
545767

546768
def _get_response_error_message(self, response):
547-
response_json = response.json()
769+
try:
770+
response_json = response.json()
771+
except Exception:
772+
# Response is not JSON, return raw text or status info
773+
return response.text or f"HTTP {response.status_code} error"
774+
548775
error_message = response_json.get("message") if response.status_code == 400 else response.text
549776
errors = response_json.get("errors", [])
550777
for error in errors:

0 commit comments

Comments
 (0)