Source code for gedih3.doctor.diagnoses.parquet_health

# 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

"""parquet_health diagnosis — corrupt files, duplicate shots, schema drift.

Three sub-checks bundled because they share the partition/parquet scan:

  - **corrupt**: open each parquet file with ``pq.ParquetFile``; flag exceptions.
    No remedy (would risk deleting user data); reported only.
  - **duplicate_shots**: count duplicate ``shot_number`` rows per partition file.
    Streamed row-group-at-a-time via ``pq.ParquetFile.iter_batches`` with
    capped readahead, then a single global ``pc.value_counts`` for the
    exact count — required because GEDI partitions interleave
    shot_numbers across row groups (each row group comes from a
    different granule). Memory scales with the file's ``shot_number``
    column size (8 B/row × N rows) plus the value_counts hashmap;
    typically <50 MB on production partitions. Remedy:
    ``parquet_dedup_partition`` (streaming, keep-first).
  - **schema_drift**: compare each partition's column set to the modal column
    set; flag outliers. Check-only — recommend running ``--fix backfill`` to
    re-join the missing product columns.

Per-partition work runs through :func:`gedih3.doctor.parallel.parallel_map`
so a registered dask client distributes the I/O.

Memory pillar (v0.8.x lessons applied):
  * One ``pq.ParquetFile`` open per file (was three: open_safely +
    count_duplicates + partition_columns).
  * ``shot_number`` is **streamed via ``iter_batches``** with capped
    readahead (``batch_readahead=1``, ``fragment_readahead=1``) and
    ``pre_buffer=True`` for I/O coalescing on shared GPFS — never the
    full ``pd.read_parquet(columns=['shot_number'])`` pull that used
    to spike workers to ~800 MB on continental partitions.
  * Explicit ``del pf`` + ``pa.default_memory_pool().release_unused()``
    after each file so worker RSS plateaus instead of climbing.
"""

from __future__ import annotations

from collections import Counter
from typing import Dict, List, Optional

from ..report import Report, DoctorContext, Severity
from ..runner import register
from ..inspect import partition_parquet_files
from ..parallel import parallel_map
from ..fused import register_scan
from ...utils import release_arrow_pool


# Memory-bounded readahead — same caps the build merge phase landed on
# (see commit f718590 / v0.8.22). batch_readahead/fragment_readahead=1
# limits the pyarrow scanner's prefetch so a single partition scan
# can't balloon a worker. ROW_GROUP_BATCH is the per-batch row count
# pulled across iter_batches calls; small enough to bound RSS, large
# enough to keep arrow's vectorized paths warm.
_ROW_GROUP_BATCH = 50_000
_PRE_BUFFER = True


def _scan_one_file(pq_file: str) -> dict:
    """Single-pass per-file scan: corrupt? dup count? column union?

    One ``pq.ParquetFile`` open feeds all three checks. The duplicate
    counter walks ``shot_number`` via ``iter_batches`` with capped
    readahead (so per-batch I/O is bounded), accumulates each batch's
    column slice into a chunked array, and runs ``pc.value_counts``
    once globally — that's the only correct way to count duplicates
    that span row groups, since GEDI partitions interleave shot_number
    ranges across row groups by design (each row group comes from a
    different granule).

    Memory scales with the file's ``shot_number`` column size (8 B/row,
    typically <50 MB on production partitions) plus the value_counts
    hashmap. Capped pyarrow readahead (``pre_buffer=True``,
    ``batch_readahead=1``, ``fragment_readahead=1``) keeps transient
    I/O bounded regardless of file size; the ``del column, chunks``
    + ``release_unused`` returns the working set to the OS before the
    next file. An earlier per-RG ``cross_rg_overlap`` proxy was dropped
    in commit ``814db7d`` because GEDI partitions normally have
    overlapping shot_number ranges across row groups, producing 100%
    false positives.

    Returns a dict (never raises): keys ``corrupt`` (bool), ``error``
    (str|None), ``duplicates`` (int), ``unreadable_shot_number`` (bool),
    ``columns`` (list[str]).
    """
    import pyarrow as pa
    import pyarrow.parquet as pq
    import pyarrow.compute as pc

    out = {
        'corrupt': False,
        'error': None,
        'duplicates': 0,
        'unreadable_shot_number': False,
        'columns': [],
    }

    pf = None
    try:
        pf = pq.ParquetFile(pq_file, pre_buffer=_PRE_BUFFER)
        # Header validity gate (touches metadata to force lazy errors).
        _ = pf.metadata.num_row_groups
        schema_names = list(pf.schema_arrow.names)
        out['columns'] = schema_names

        if 'shot_number' in schema_names:
            try:
                # Exact global duplicate count via streaming iter_batches +
                # a single ``pc.value_counts`` over the chunked column.
                #
                # Cross-row-group dups are the realistic case here: a
                # partition's row groups come from different granules
                # whose shot_numbers can interleave (overlapping min/max
                # is the normal post-merge state, NOT a defect signal).
                # An exact global count is the only correct signal.
                #
                # Memory: chunked_array holds references to each batch's
                # int64 column (~8 B/row of the file's shot_number), and
                # ``pc.value_counts`` builds a native C++ hashmap of size
                # O(unique shot_numbers). For typical GEDI partitions
                # (~100k rows) this is well under 5 MB total. For
                # pathological 10M+-row files it scales linearly with
                # row count, but readahead is capped via pre_buffer + the
                # 50k batch_size, so peak transient I/O is bounded
                # regardless.
                chunks = []
                for batch in pf.iter_batches(
                    batch_size=_ROW_GROUP_BATCH,
                    columns=['shot_number'],
                    use_threads=False,
                ):
                    chunks.append(batch.column('shot_number'))
                    del batch
                if chunks:
                    if len(chunks) == 1:
                        column = chunks[0]
                    else:
                        column = pa.chunked_array(chunks)
                    vc = pc.value_counts(column)
                    counts_np = vc.field('counts').to_numpy(zero_copy_only=False)
                    if counts_np.size:
                        out['duplicates'] = int((counts_np - 1).clip(min=0).sum())
                    del vc, counts_np, column, chunks
            except Exception:
                out['unreadable_shot_number'] = True
    except Exception as e:
        out['corrupt'] = True
        out['error'] = f"{type(e).__name__}: {e}"
    finally:
        if pf is not None:
            try:
                pf.close()
            except Exception:
                pass
            del pf
        release_arrow_pool()

    return out


def _scan_partition(
    partition_dir: str,
    *,
    shared: Optional[dict] = None,
) -> dict:
    """Worker: scan one partition, return per-file findings + column union.

    Per-file work goes through :func:`_scan_one_file` so each parquet
    is opened exactly once (header + shot_number stream + schema all in
    one pass) and its allocator freed before the next.

    Fused-aware: when ``shared`` is provided, reads the cached parquet
    listing instead of re-globbing.
    """
    if shared is not None and 'parquet_files' in shared:
        files = shared['parquet_files']
    else:
        files = partition_parquet_files(partition_dir)
    findings = []
    cols_union: set = set()
    for pq_file in files:
        info = _scan_one_file(pq_file)
        if info['corrupt']:
            findings.append({'kind': 'corrupt', 'path': pq_file, 'error': info['error']})
            continue
        if info['unreadable_shot_number']:
            findings.append({'kind': 'unreadable_shot_number', 'path': pq_file})
        if info['duplicates'] > 0:
            findings.append({
                'kind': 'duplicate_shots', 'path': pq_file,
                'duplicates': info['duplicates'],
            })
        cols_union.update(info['columns'])

    return {'findings': findings, 'columns': frozenset(cols_union)}


register_scan('parquet_health', _scan_partition)


def _finalize_parquet_health_check(
    ctx: DoctorContext,
    per_partition: Dict[str, dict],
) -> Report:
    """Driver-side aggregation of parquet_health per-partition results.

    Computes schema-drift findings post-hoc from the per-partition
    column unions (requires all partitions seen before the modal
    schema is known).
    """
    findings: List[dict] = []
    schema_by_part: Dict[str, frozenset] = {}

    for part_dir, result in per_partition.items():
        if isinstance(result, Exception):
            findings.append({
                'kind': 'scan_error',
                'partition_dir': part_dir,
                'error': f"{type(result).__name__}: {result}",
            })
            continue
        findings.extend(result['findings'])
        schema_by_part[part_dir] = result['columns']

    # Schema drift: find the modal column set and flag partitions that differ.
    if len(schema_by_part) >= 3:
        counts = Counter(schema_by_part.values())
        modal_schema, _ = counts.most_common(1)[0]
        for part_dir, cols in schema_by_part.items():
            if cols != modal_schema:
                missing = sorted(modal_schema - cols)
                extra = sorted(cols - modal_schema)
                findings.append({
                    'kind': 'schema_drift',
                    'partition_dir': part_dir,
                    'missing_columns': missing,
                    'extra_columns': extra,
                })

    n_corrupt = sum(1 for f in findings if f['kind'] == 'corrupt')
    n_dup = sum(1 for f in findings if f['kind'] == 'duplicate_shots')
    n_drift = sum(1 for f in findings if f['kind'] == 'schema_drift')
    n_scan_err = sum(1 for f in findings if f['kind'] == 'scan_error')

    if n_corrupt or n_scan_err:
        severity = Severity.ERROR
    elif findings:
        severity = Severity.WARN
    else:
        severity = Severity.INFO

    summary = f"{n_corrupt} corrupt, {n_dup} files with duplicates, {n_drift} schema-drift partitions"
    if n_scan_err:
        summary += f", {n_scan_err} partitions errored during scan"

    recommendations = []
    if n_drift:
        recommendations.append(
            "gh3_doctor --fix backfill   # refill missing-column partitions from source"
        )
    if n_corrupt:
        recommendations.append(
            "Review corrupt files before deletion; gh3_doctor will not auto-delete data."
        )

    return Report(
        name='parquet_health', severity=severity,
        findings=findings, summary=summary, recommendations=recommendations,
    )


[docs] def parquet_health_check(ctx: DoctorContext) -> Report: per_partition: Dict[str, dict] = {} for part_dir, result in parallel_map( ctx.partition_dirs, _scan_partition, args=getattr(ctx, 'args', None), desc='parquet_health: scanning partitions', unit='part', ): per_partition[part_dir] = result return _finalize_parquet_health_check(ctx, per_partition)
[docs] def parquet_health_fix(ctx: DoctorContext, report: Report) -> Report: """Fix only what's safely fixable: duplicate_shots.""" from ..parquet_ops import parquet_dedup_partition fixed = [] for f in report.findings: kind = f.get('kind') if kind == 'duplicate_shots': try: dropped = parquet_dedup_partition(f['path']) fixed.append({**f, 'action': 'deduplicated', 'dropped': dropped}) except Exception as e: fixed.append({**f, 'fix_error': f"{type(e).__name__}: {e}"}) elif kind in ('corrupt', 'schema_drift', 'unreadable_shot_number', 'scan_error'): # Preserve as-is — no auto-fix. fixed.append({**f, 'action': 'reported_only'}) else: fixed.append(f) n_errors = sum(1 for x in fixed if 'fix_error' in x) n_corrupt = sum(1 for x in fixed if x['kind'] == 'corrupt') report.applied = True report.findings = fixed if n_errors or n_corrupt: report.severity = Severity.ERROR if n_corrupt else Severity.WARN report.summary = ( f"{sum(1 for x in fixed if x.get('action') == 'deduplicated')} dedups; " f"{n_corrupt} corrupt unresolved; {n_errors} fix errors" ) else: report.severity = Severity.INFO report.summary = f"{sum(1 for x in fixed if x.get('action') == 'deduplicated')} dedups complete" return report
register('parquet_health', 'corrupt files + duplicate shots + schema drift', scope='global', fix=parquet_health_fix)(parquet_health_check)