Skip to content

Commit 4e9fdc6

Browse files
authored
Merge pull request #57 from mirzaees/s3_bucket_download
S3 bucket download
2 parents 9c271a1 + c55480a commit 4e9fdc6

5 files changed

Lines changed: 187 additions & 306 deletions

File tree

docker/Dockerfile

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,9 @@ COPY --chown=$MAMBA_USER:$MAMBA_USER docker/specfile.txt /tmp/specfile.txt
6666
# "Operation too slow"), which would otherwise fail the whole build on a single
6767
# flaky transfer. Retry the install a few times before giving up.
6868
RUN for attempt in 1 2 3 4 5; do \
69-
micromamba install --yes --channel conda-forge -n base -f /tmp/specfile.txt && break; \
70-
if [ "$attempt" = "5" ]; then echo "micromamba install failed after $attempt attempts" && exit 1; fi; \
71-
echo "micromamba install attempt $attempt stalled; retrying in 15s..." && sleep 15; \
69+
micromamba install --yes --channel conda-forge -n base -f /tmp/specfile.txt && break; \
70+
if [ "$attempt" = "5" ]; then echo "micromamba install failed after $attempt attempts" && exit 1; fi; \
71+
echo "micromamba install attempt $attempt stalled; retrying in 15s..." && sleep 15; \
7272
done && \
7373
micromamba clean --all --yes
7474

@@ -82,8 +82,8 @@ ARG MAMBA_DOCKERFILE_ACTIVATE=1
8282
# satisfies spurt's `numpy>=1.23`, so install without the constraint.
8383
RUN pip install git+https://github.com/isce-framework/spurt@v0.1.1
8484
# --no-deps because they are installed with conda
85-
RUN pip install rich && pip install --no-deps git+https://github.com/opera-adt/opera-utils@v0.25.7
86-
RUN pip install tyro && pip install --no-deps git+https://github.com/isce-framework/dolphin@v0.42.6
85+
RUN pip install rich && pip install --no-deps git+https://github.com/opera-adt/opera-utils@v0.25.8
86+
RUN pip install tyro && pip install --no-deps git+https://github.com/isce-framework/dolphin@v0.42.7
8787

8888
COPY --chown=$MAMBA_USER:$MAMBA_USER . .
8989
RUN python -m pip install --no-deps .

src/disp_nisar/_remote_input.py

Lines changed: 140 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@
1414

1515
import logging
1616
import re
17+
import threading
1718
from concurrent.futures import ThreadPoolExecutor, as_completed
1819
from pathlib import Path
19-
from typing import Iterable, Sequence
20+
from typing import Iterable, NamedTuple, Sequence
2021

2122
import h5netcdf
2223
import h5py
2324
from opera_utils import is_remote_url
25+
from opera_utils._types import PathOrStr
2426
from tenacity import retry, stop_after_attempt, wait_fixed
2527

2628
from ._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+
498615
def 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

Comments
 (0)