11import datetime
22import json
3+ import logging
4+ import math
5+ import os
36import re
47import urllib .request
8+ from concurrent .futures import (
9+ as_completed ,
10+ ThreadPoolExecutor ,
11+ )
512from typing import (
613 Any ,
714 cast ,
1017from urllib .error import HTTPError
1118from urllib .parse import quote
1219
20+ log = logging .getLogger (__name__ )
21+
1322from 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+
107166class 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