1414
1515import logging
1616import re
17+ import threading
1718from concurrent .futures import ThreadPoolExecutor , as_completed
1819from pathlib import Path
19- from typing import Iterable , Sequence
20+ from typing import Iterable , NamedTuple , Sequence
2021
2122import h5netcdf
2223import h5py
2324from opera_utils import is_remote_url
25+ from opera_utils ._types import PathOrStr
2426from tenacity import retry , stop_after_attempt , wait_fixed
2527
2628from ._streaming import S3Path
@@ -417,16 +419,7 @@ def parallel_s3_download(
417419 from ._streaming import get_authorized_s3_client
418420
419421 s3_client = get_authorized_s3_client (dataset = "nisar" )
420- # for url in s3_urls:
421- # out = _download_file(
422- # s3_client,
423- # url,
424- # output_dir,
425- # raw_dir,
426- # frequencies,
427- # polarization,
428- # )
429- # downloaded_files.append[out]
422+
430423 with concurrent .futures .ThreadPoolExecutor (max_workers = max_workers ) as executor :
431424 future_to_url = {}
432425 for url in s3_urls :
@@ -495,6 +488,130 @@ def _download_file(
495488 return final
496489
497490
491+ class SubsetResult (NamedTuple ):
492+ url : str
493+ output : str
494+ error : str
495+
496+
497+ def download_and_subset_from_private_bucket (
498+ urls : Sequence [str ],
499+ raw_dir : PathOrStr ,
500+ output_dir : PathOrStr ,
501+ frequencies : Sequence [str ],
502+ polarization : str ,
503+ * ,
504+ num_workers : int = 4 ,
505+ per_file_concurrency : int = 8 ,
506+ aws_profile : str | None = None ,
507+ ) -> list :
508+ """Download each ``s3://bucket/key`` URL, subset it, delete the original.
509+
510+ Each URL is handled as one unit: download -> ``_extract_subset`` -> delete.
511+ A bounded thread pool runs at most ``num_workers`` units at once, so peak local
512+ disk stays ~ ``num_workers x (original + subset)``. Downloads overlap; the h5py
513+ repack is serialised behind a lock because HDF5 is not thread-safe.
514+
515+ ``_extract_subset`` must be importable in the enclosing scope.
516+
517+ Returns a list of ``SubsetResult(url, output, error)`` named tuples.
518+ """
519+ from urllib .parse import urlparse
520+
521+ import boto3
522+ from boto3 .s3 .transfer import TransferConfig
523+ from botocore .config import Config
524+
525+ log = logging .getLogger (__name__ )
526+
527+ urls = list (urls )
528+ if not urls :
529+ log .warning ("no urls given" )
530+ return []
531+
532+ download_dir = Path (raw_dir )
533+ output_dir = Path (output_dir )
534+ download_dir .mkdir (parents = True , exist_ok = True )
535+ output_dir .mkdir (parents = True , exist_ok = True )
536+
537+ # One client, shared across threads. The pool must hold enough connections for
538+ # every worker to run its own multipart download at the same time.
539+ session = boto3 .Session (profile_name = aws_profile )
540+ client = session .client (
541+ "s3" ,
542+ config = Config (
543+ max_pool_connections = num_workers * per_file_concurrency + 4 ,
544+ retries = {"max_attempts" : 10 , "mode" : "adaptive" },
545+ ),
546+ )
547+ transfer_config = TransferConfig (max_concurrency = per_file_concurrency )
548+ repack_lock = threading .Lock ()
549+
550+ def _split (url : str ) -> tuple [str , str ]:
551+ p = urlparse (url )
552+ if p .scheme != "s3" or not p .netloc or not p .path .strip ("/" ):
553+ raise ValueError (f"not an s3://bucket/key url: { url !r} " )
554+ return p .netloc , p .path .lstrip ("/" )
555+
556+ def _one (url : str ) -> SubsetResult :
557+ try :
558+ bucket , key = _split (url )
559+ except ValueError as e :
560+ return SubsetResult (url , "None" , str (e ))
561+
562+ local = download_dir / Path (key ).name
563+ dst = output_dir / Path (key ).name
564+
565+ # 1) download
566+ try :
567+ if not dst .exists ():
568+ client .download_file (bucket , key , str (local ), Config = transfer_config )
569+ except Exception as e : # noqa: BLE001
570+ local .unlink (missing_ok = True ) # drop any partial file
571+ log .exception ("download failed: %s" , url )
572+ return SubsetResult (url , "None" , f"download failed: { e } " )
573+
574+ # 2) subset / repack (serialised: HDF5 is not thread-safe)
575+ try :
576+ with repack_lock :
577+ if local .exists ():
578+ _extract_subset (local , dst , frequencies , polarization )
579+ except Exception as e : # noqa: BLE001
580+ dst .unlink (missing_ok = True ) # drop partial subset
581+ # Keep the original so the repack can be retried without re-downloading.
582+ log .exception ("subset failed (original kept): %s" , url )
583+ return SubsetResult (url , "None" , f"subset failed: { e } " )
584+
585+ # `_extract_subset` no-ops for unrecognised products: only delete the
586+ # original if a subset was actually written.
587+ if not dst .exists ():
588+ log .info ("no subset produced for %s (unrecognised product?)" , url )
589+ return SubsetResult (url , "None" , "None" )
590+
591+ local .unlink (missing_ok = True )
592+ return SubsetResult (url , dst , "None" )
593+
594+ log .info (
595+ "%d urls; starting bounded pipeline (num_workers=%d, per_file_concurrency=%d)" ,
596+ len (urls ),
597+ num_workers ,
598+ per_file_concurrency ,
599+ )
600+
601+ results : list = []
602+ with ThreadPoolExecutor (max_workers = num_workers ) as pool :
603+ futures = {pool .submit (_one , url ): url for url in urls }
604+ for i , fut in enumerate (as_completed (futures ), start = 1 ):
605+ res = fut .result ()
606+ results .append (res )
607+ status = "ok" if res .error == "None" else f"ERROR ({ res .error } )"
608+ log .info ("[%d/%d] %s -> %s" , i , len (urls ), res .url , status )
609+
610+ n_ok = sum (r .error == "None" for r in results )
611+ log .info ("done: %d ok, %d failed" , n_ok , len (results ) - n_ok )
612+ return [r .output for r in results ]
613+
614+
498615def stage_remote_inputs (
499616 urls : Iterable [str | Path ],
500617 scratch_dir : Path ,
@@ -550,6 +667,8 @@ def stage_remote_inputs(
550667
551668 https_urls = [u for u in url_list if u .startswith ("https://" )]
552669 s3_urls = [u for u in url_list if u .startswith ("s3://" )]
670+ s3_urls_private = [u for u in s3_urls if "cumulus" not in u ]
671+ s3_urls_asf = [u for u in s3_urls if u not in s3_urls_private ]
553672
554673 if https_urls :
555674 with ThreadPoolExecutor (max_workers = n_workers ) as pool :
@@ -567,9 +686,17 @@ def stage_remote_inputs(
567686 for fut in as_completed (future_to_idx ):
568687 i = future_to_idx [fut ]
569688 out_paths [i ] = fut .result ()
570- elif s3_urls :
689+ elif s3_urls_private :
690+ out_paths = download_and_subset_from_private_bucket (
691+ urls = s3_urls_private ,
692+ raw_dir = raw_dir ,
693+ output_dir = scratch_dir ,
694+ frequencies = frequencies ,
695+ polarization = polarization ,
696+ )
697+ else :
571698 out_paths = parallel_s3_download (
572- s3_urls = s3_urls ,
699+ s3_urls = s3_urls_asf ,
573700 output_dir = scratch_dir ,
574701 raw_dir = raw_dir ,
575702 frequencies = frequencies ,
0 commit comments