Source code for gedih3.parallel

# Copyright (C) 2026, University of Maryland. All Rights Reserved.
# Authors: Tiago de Conto, Amelia Grace Holcomb
# For commercial licensing inquiries, contact UM Ventures at umdtechtransfer@umd.edu

"""Package-wide parallelism primitives for gedih3.

Houses the always-parallel ``parallel_map`` (originally introduced for
gh3_doctor diagnoses; promoted here because the build, download, and
manifest-writing paths all depend on it now) plus three parallel walker
primitives used to regenerate SOC and H3-DB manifests via dask workers
instead of driver-side serial recursive globs.

Design rules (carried forward from the v0.8.x build-pipeline lessons
and the doctor refactor):

  * **Always parallel.** No serial fallback. A registered
    :class:`~dask.distributed.Client` is required; library / notebook
    callers must wrap their call site in ``with Client(...) as c: ...``.
    Single code path means no quietly-different behavior on small inputs.
  * **Workers receive only what they need.** Filter args (exclude
    patterns, glob patterns, …) flow through ``parallel_map``'s
    ``**broadcast`` kwargs — never via closure capture — so worker fns
    stay picklable as top-level module functions.
  * **Stream ``as_completed``** instead of ``persist + compute``.
    Findings accumulate as each future finishes; failures on
    individual items surface as exception objects in the yielded stream.
  * **Driver-side aggregation is cheap.** The walkers flatten + sort
    the per-leaf results on the driver as they arrive — no large
    materialized intermediates.
  * **Fail loud.** A worker exception in a walker aborts the walk;
    an incomplete manifest is a worse footgun than a slow re-walk.
"""

from __future__ import annotations

import fnmatch
import glob
import os
from typing import Any, Callable, Iterator, List, Optional, Tuple

from .exceptions import GediError
from .logging_config import get_logger

logger = get_logger(__name__)


# ─── parallel_map (moved from doctor/parallel.py) ─────────────────────────


[docs] def parallel_map( items: List[Any], fn: Callable[..., Any], *, args=None, desc: str = '', unit: str = 'item', batch_size: int = 0, **broadcast, ) -> Iterator[Tuple[Any, Any]]: """Map ``fn`` across ``items`` on a dask cluster. Yields ``(item, result)`` tuples in completion order. When ``fn`` raises on a worker, ``result`` is the exception instance — callers decide how to surface it (turn into a finding, log, or re-raise). Requires a dask :class:`~dask.distributed.Client` to be registered (via :func:`gedih3.utils.get_dask_client`). The gedih3 CLI tools create one at startup; library / notebook callers must wrap their call site in ``with Client(...) as client: ...``. Raises :class:`gedih3.exceptions.GediError` when no client is registered. Parameters ---------- items : list Iterable of work items (e.g. partition directory paths). fn : callable Top-level (picklable) function. Signature: ``fn(item, **broadcast)``. args : argparse.Namespace, optional Accepted for API compatibility with callers that pass it; not consumed because the always-parallel path doesn't need a tqdm progress bar — the dask dashboard is the live view. desc, unit : str Dispatch log prefix and unit name used in the periodic ``N/M done`` counter line. batch_size : int, optional When >0, group items into batches of this size and dispatch one dask task per batch (each task internally iterates its slice). Use this when ``len(items)`` is in the hundreds-of-thousands — ``client.map`` builds a task graph proportional to ``len(items)`` and submission/scheduler overhead dominates the actual work beyond ~10k tasks. **broadcast Constant keyword arguments forwarded to every call of ``fn``. """ del args # accepted for API stability; not used in always-parallel path items = list(items) if not items: return # Lazy import keeps ``utils`` -> ``parallel`` -> ``utils`` from cycling # at module-init time. The dask client is only needed at call time # anyway. from .utils import get_dask_client client = get_dask_client() if client is None: raise GediError( "parallel_map requires a registered dask.distributed Client. " "CLI tools (gh3_doctor, gh3_extract, gh3_aggregate, …) create " "one automatically; library / notebook callers must wrap their " "code in `with dask.distributed.Client(...) as client: ...`." ) from dask.distributed import as_completed as dask_as_completed from tqdm import tqdm as tqdm_bar # Periodic-progress INFO is opt-in only — tqdm already shows live # progress in the terminal, and the dask dashboard is the canonical # cluster view. Set ``GH3_LOG_PROGRESS=1`` to re-enable the 5%-step # INFO lines for detached / tail-followed log workflows. log_progress = os.environ.get( 'GH3_LOG_PROGRESS', '').strip().lower() in ('1', 'true', 'yes', 'on') if batch_size and batch_size > 0 and len(items) > batch_size: chunks = [items[i:i + batch_size] for i in range(0, len(items), batch_size)] logger.info( f"{desc}: dispatching {len(chunks)} batches " f"of up to {batch_size} {unit}s " f"({len(items)} total) to dask cluster (progress on dashboard)" ) # pure=False: every fn shipped through parallel_map reads live # filesystem state (directory scans, h5 validity, metadata reads). # Dask's default pure=True hashes (fn, args) into a deterministic # task key and reuses cached results for identical keys — a repeat # of the same scan in one process (manifest write -> doctor scan, # --fix -> re-scan) could silently return stale pre-mutation # results when the resubmission races the async release of the # previous futures. futures = client.map(_run_chunk, chunks, fn=fn, broadcast=broadcast, pure=False) log_every = max(1, len(chunks) // 20) n_done_chunks = 0 n_done_items = 0 pbar = tqdm_bar(total=len(items), desc=desc or 'parallel_map', unit=unit) try: for fut in dask_as_completed(futures): try: pairs = fut.result() except Exception as e: yield None, e pairs = [] for it, res in pairs: yield it, res n_done_items += 1 pbar.update(1) n_done_chunks += 1 if log_progress and ( n_done_chunks == 1 or n_done_chunks == len(chunks) or n_done_chunks % log_every == 0 ): logger.info( f"{desc}: {n_done_chunks}/{len(chunks)} batches " f"({n_done_items}/{len(items)} {unit}s) done" ) finally: pbar.close() return logger.info( f"{desc}: dispatching {len(items)} tasks to dask cluster " f"(progress on dashboard)" ) # pure=False — see the batched branch above: parallel_map fns are # impure filesystem readers; key-cached results would be stale. futures = client.map(fn, items, pure=False, **broadcast) fut2item = {f: it for f, it in zip(futures, items)} log_every = max(1, len(items) // 20) n_done = 0 pbar = tqdm_bar(total=len(items), desc=desc or 'parallel_map', unit=unit) try: for fut in dask_as_completed(futures): item = fut2item.get(fut) try: res = fut.result() except Exception as e: res = e n_done += 1 pbar.update(1) if log_progress and ( n_done == 1 or n_done == len(items) or n_done % log_every == 0 ): logger.info(f"{desc}: {n_done}/{len(items)} {unit}s done") yield item, res finally: pbar.close()
def _run_chunk(chunk: List[Any], *, fn: Callable[..., Any], broadcast: dict) -> List[Tuple[Any, Any]]: """Worker entry point for batched dispatch. Top-level so dask can pickle it; not for direct use.""" out = [] for it in chunk: try: out.append((it, fn(it, **broadcast))) except Exception as e: out.append((it, e)) return out # ─── parallel walkers ───────────────────────────────────────────────────── # # Three primitives, one shared shape: # 1. driver enumerates leaf directories via os.scandir (bounded) # 2. parallel_map dispatches per-leaf scan to dask workers # 3. driver flattens + sorts + returns # 4. fail-loud on any worker exception (no partial result) # # All filter args (pattern, exclude) flow through parallel_map's # **broadcast — never as closure capture — so worker fns are top-level # and picklable. def _scan_doy_dir(doy_dir: str, *, pattern: str, exclude: Optional[List[str]] = None) -> List[str]: """SOC walker worker. One non-recursive scandir per doy directory.""" out = [] try: for entry in os.scandir(doy_dir): if not entry.is_file(follow_symlinks=False): continue name = entry.name if not fnmatch.fnmatch(name, pattern): continue if exclude and any(fnmatch.fnmatch(name, p) for p in exclude): continue out.append(entry.path) except OSError as e: # Re-raise so parallel_map surfaces the failure and the walker # aborts (R2 contract: never produce a partial manifest). raise OSError(f"scandir failed at {doy_dir}: {e}") from e return out def _scan_h3_partition(part_dir: str, *, pattern: str) -> List[str]: """H3-DB walker worker. Recursive scan inside one h3 partition dir. Handles both flat (``h3_NN=*/foo.parquet``) and year-nested (``h3_NN=*/year=NNNN/foo.parquet``) layouts.""" return sorted(glob.glob(os.path.join(part_dir, '**', pattern), recursive=True)) def _scan_flat_dir(dir_path: str, *, pattern: str) -> List[str]: """Flat-tree worker. One non-recursive scandir.""" out = [] try: for entry in os.scandir(dir_path): if entry.is_file(follow_symlinks=False) and fnmatch.fnmatch( entry.name, pattern): out.append(entry.path) except OSError as e: raise OSError(f"scandir failed at {dir_path}: {e}") from e return out
[docs] def walk_soc_parallel( soc_dir: str, *, pattern: str = 'GEDI*.h5', exclude: Optional[List[str]] = None, batch_size: int = 32, ) -> List[str]: """Parallel year/doy walk over a SOC tree. Driver enumerates year and doy subdirs via :func:`os.scandir` (bounded: ~7 years × ~300 doys = ~2000 single-level syscalls on the driver). Workers run one non-recursive scandir per doy via :func:`_scan_doy_dir`, applying ``pattern`` and ``exclude`` locally so we never ship unwanted paths back across the network. Always parallel — requires a registered dask Client. """ year_dirs = [] try: for entry in os.scandir(soc_dir): if entry.is_dir(follow_symlinks=False) and entry.name.isdigit(): year_dirs.append(entry.path) except OSError: return [] doy_dirs = [] for y in sorted(year_dirs): try: for entry in os.scandir(y): if entry.is_dir(follow_symlinks=False): doy_dirs.append(entry.path) except OSError: continue # Degenerate case: no ``YYYY/DOY/`` structure at the root (test # fixtures, small ad-hoc deliveries, the very first download of # the day before subdirs exist). Treat the root itself as a # single leaf. if not doy_dirs: doy_dirs = [soc_dir] files: List[str] = [] for _, res in parallel_map( sorted(doy_dirs), _scan_doy_dir, desc=f"walk_soc_parallel({soc_dir})", unit='doy', batch_size=batch_size, pattern=pattern, exclude=exclude, ): if isinstance(res, Exception): raise GediError( f"walk_soc_parallel: worker failed — aborting walk to avoid " f"writing a partial manifest. Original: {type(res).__name__}: {res}" ) files.extend(res) return sorted(files)
[docs] def walk_h3db_parallel( db_root: str, *, pattern: str = '*.parquet', batch_size: int = 64, ) -> List[str]: """Parallel per-partition walk over an H3 database. Driver enumerates ``h3_NN=*`` partition directories via one :func:`os.scandir` on the DB root (typically ~10k entries at partition level 3). Workers recursively glob each partition (handles both flat and ``year=NNNN/``-nested layouts). Always parallel — requires a registered dask Client. """ partition_dirs = [] try: for entry in os.scandir(db_root): if entry.is_dir(follow_symlinks=False) and entry.name.startswith('h3_'): partition_dirs.append(entry.path) except OSError: return [] if not partition_dirs: return [] files: List[str] = [] for _, res in parallel_map( sorted(partition_dirs), _scan_h3_partition, desc=f"walk_h3db_parallel({db_root})", unit='partition', batch_size=batch_size, pattern=pattern, ): if isinstance(res, Exception): raise GediError( f"walk_h3db_parallel: worker failed — aborting walk to avoid " f"writing a partial manifest. Original: {type(res).__name__}: {res}" ) files.extend(res) return sorted(files)
[docs] def walk_flat_parallel( dir_path: str, *, pattern: str = '*.parquet', ) -> List[str]: """Flat single-directory listing. Provided for API symmetry with the other two walkers; no dask dispatch (the tree has a single leaf).""" return sorted(_scan_flat_dir(dir_path, pattern=pattern))
# ─── manifest freshness smoke check (R2 + producer-crash guard) ─────────── # # R2 is producer-driven: every code path that mutates the tree refreshes # the manifest before returning. A producer that SIGKILLs between writing # the last file and writing the manifest leaves a stale manifest on disk # and the next consumer would silently miss the new files. # # This cheap smoke check (two ``os.stat`` calls) catches that case and # emits a loud, actionable error directing the user to the doctor remedy. # It does NOT auto-refresh — that would violate R2's "consumers trust the # manifest" contract. The check is constant-time regardless of tree size.
[docs] def check_manifest_freshness( manifest_path: str, root_dir: str, *, raise_on_stale: bool = False, remedy: str = '', ) -> bool: """Compare ``mtime(manifest_path)`` against ``mtime(root_dir)``. Returns True when the manifest is at least as new as the root directory's mtime. Returns False (and logs a loud error / raises) when the root dir was touched after the manifest was written — indicating either a producer crashed before refreshing the manifest or files were dropped in externally (e.g. NASA delivery, manual rsync). Parameters ---------- manifest_path Absolute path to the manifest file. root_dir Absolute path to the tree the manifest indexes. raise_on_stale When True, raise :class:`gedih3.exceptions.GediError` on staleness. When False (default), log an ERROR and return False. remedy Shell command the user should run to refresh the manifest, included in the error message. """ try: m_mtime = os.stat(manifest_path).st_mtime_ns r_mtime = os.stat(root_dir).st_mtime_ns except OSError: # Manifest or root missing — leave the decision to the caller's # existing missing-manifest fallback. Not our case to flag. return True if m_mtime >= r_mtime: return True msg = ( f"manifest {manifest_path} is older than {root_dir} " f"({m_mtime} < {r_mtime}). The tree was modified after the last " f"producer refresh — likely a producer crash or external " f"population (NASA delivery, manual rsync)." ) if remedy: msg += f" Remedy: {remedy}" if raise_on_stale: raise GediError(msg) # WARNING, not ERROR — the check is a heuristic on directory mtime, not a # content check. A create+delete of a short-lived temp file (e.g. atomic # writes from an unrelated tool earlier) bumps the dir mtime without # invalidating the manifest contents, so this fires routinely on # perfectly correct manifests. Downstream code still uses the manifest; # this is advisory. logger.warning(msg) return False