Source code for gedih3.doctor.diagnoses.tmp_partitions_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

"""tmp_partitions_health diagnosis — post-build forensics on ``tmp/partitions/``.

Audit-only check that NEVER iterates healthy partitions (Pillar 1 — no
driver-side O(N) GPFS scans). It acts strictly on the signals the build
already persisted:

  * ``tmp_dir/_merge_failures/*.fail`` sentinels  (one per failed merge)
  * ``tmp_dir/_granule_failures.jsonl``           (one record per failed granule)
  * ``tmp_dir/_merge_progress.txt`` vs. the DB ``_manifest.txt`` line count

The cost of the check is O(N_failures) + O(1) on the manifest — never
O(N_partitions). All work runs on the driver because the inputs are
narrow (a single sentinel directory + two flat files).

The ``--fix`` variant calls :func:`gh3builder.preclean_merge_failures` to
remove zero-byte / unreadable parquets + ``.tmp`` siblings under every
failed-merge partition. Granule failures are reported only — recovery
belongs in ``gh3_update --recover-missing-vars``. As a hard guard, the
fix refuses to act when a fresh ``gh3_build`` process appears to be
running (recent ``gh3_build.log`` mtime + matching pgrep PID).
"""

from __future__ import annotations

import os
import subprocess
import time
from collections import defaultdict
from typing import List

from ..report import Report, DoctorContext, Severity
from ..runner import register
from ... import gh3builder


# Tolerance for the progress vs. manifest drift check. The merge progress
# file is append-only and can lag the manifest by a few entries during a
# concurrent run; we only flag a meaningful gap.
_DRIFT_TOLERANCE = 5

# Build-active guard: a fresh log + a live pgrep hit means a build is
# concurrently writing tmp/partitions/ and any --fix would race the writer.
_BUILD_LOG_FRESH_SECONDS = 60.0


def _count_manifest_entries(h3_dir: str) -> int:
    """O(1) line count of the DB's ``_manifest.txt`` sentinel.

    Uses :func:`gedih3.utils._read_manifest` when available so the manifest
    cache is shared with the rest of the loader; falls back to a direct
    line-count on the file when the helper is missing or returns None.
    """
    try:
        from ...utils import _read_manifest
        lines = _read_manifest(h3_dir)
        if lines is not None:
            return len(lines)
    except Exception:
        pass

    from ...config import MANIFEST_FILENAME
    manifest_path = os.path.join(h3_dir, MANIFEST_FILENAME)
    if not os.path.isfile(manifest_path):
        return 0
    try:
        with open(manifest_path, 'r') as f:
            return sum(1 for line in f if line.strip())
    except OSError:
        return 0


def _count_merge_progress_entries(tmp_dir: str) -> int:
    """O(1) line count of ``_merge_progress.txt`` under tmp_dir."""
    path = os.path.join(tmp_dir, '_merge_progress.txt')
    if not os.path.isfile(path):
        return 0
    try:
        with open(path, 'r') as f:
            return sum(1 for line in f if line.strip())
    except OSError:
        return 0


def _build_is_active(h3_dir: str, tmp_dir: str):
    """Return ``(active: bool, info: dict)``.

    Cheap signal: a ``gh3_build.log`` whose mtime is < 60 s old AND a
    matching ``pgrep -f gh3_build`` hit. Run only at fix time. False
    negative (build with stale log) is acceptable — the user can re-run
    after the build settles.
    """
    candidates = [
        os.path.join(h3_dir, 'gh3_build.log'),
        os.path.join(os.path.dirname(h3_dir.rstrip('/')), 'gh3_build.log'),
        os.path.join(tmp_dir, 'gh3_build.log') if tmp_dir else None,
    ]
    fresh_log = None
    for c in candidates:
        if not c or not os.path.isfile(c):
            continue
        try:
            age = time.time() - os.path.getmtime(c)
        except OSError:
            continue
        if age < _BUILD_LOG_FRESH_SECONDS:
            fresh_log = c
            break

    if fresh_log is None:
        return False, {}

    try:
        res = subprocess.run(
            ['pgrep', '-f', 'gh3_build'],
            capture_output=True, text=True, timeout=5,
        )
        pids = [p for p in res.stdout.split() if p.strip()]
    except (OSError, subprocess.SubprocessError):
        pids = []

    if pids:
        return True, {'log': fresh_log, 'pid': int(pids[0])}
    return False, {}


[docs] def tmp_partitions_health_check(ctx: DoctorContext) -> Report: findings: List[dict] = [] tmp_dir = getattr(ctx, 'tmp_dir', None) if not tmp_dir or not os.path.isdir(tmp_dir): return Report( name='tmp_partitions_health', severity=Severity.INFO, findings=[], summary='no tmp_dir present (one-shot build); nothing to audit', ) # 1. Merge-failure sentinels — one finding per recorded failure. sentinels = gh3builder._scan_merge_failure_sentinels(tmp_dir) for partition_dir, err in sentinels.items(): findings.append({ 'kind': 'merge_failure', 'partition_dir': partition_dir, 'error': err, }) # 2. Granule failures — grouped by ``kind``; up to 5 examples per group. granule_records = gh3builder._read_granule_failures(tmp_dir) by_kind: dict = defaultdict(list) for rec in granule_records: by_kind[rec.get('kind', 'unknown')].append(rec) for kind, recs in by_kind.items(): findings.append({ 'kind': 'granule_failures', 'failure_kind': kind, 'count': len(recs), 'examples': recs[:5], }) # 3. Progress vs. manifest drift — O(1) on both ends. progress_count = _count_merge_progress_entries(tmp_dir) manifest_count = _count_manifest_entries(ctx.h3_dir) if progress_count > manifest_count + _DRIFT_TOLERANCE: findings.append({ 'kind': 'progress_manifest_drift', 'progress_count': progress_count, 'manifest_count': manifest_count, }) n_merge = sum(1 for f in findings if f['kind'] == 'merge_failure') n_gran_groups = sum(1 for f in findings if f['kind'] == 'granule_failures') n_gran_records = sum(f['count'] for f in findings if f['kind'] == 'granule_failures') n_drift = sum(1 for f in findings if f['kind'] == 'progress_manifest_drift') summary = ( f"{n_merge} failed merges, " f"{n_gran_records} granule failures across {n_gran_groups} kind(s), " f"{n_drift} drift finding(s)" ) severity = Severity.WARN if findings else Severity.INFO return Report( name='tmp_partitions_health', severity=severity, findings=findings, summary=summary, )
[docs] def tmp_partitions_health_fix(ctx: DoctorContext, report: Report) -> Report: tmp_dir = getattr(ctx, 'tmp_dir', None) if not tmp_dir or not os.path.isdir(tmp_dir): report.applied = True report.severity = Severity.INFO report.summary = 'no tmp_dir present; nothing to fix' return report # Build-active guard — refuse to mutate state while a build is writing. active, info = _build_is_active(ctx.h3_dir, tmp_dir) if active: report.applied = True report.severity = Severity.ERROR report.findings = [{ 'kind': 'build_active', 'pid': info.get('pid'), 'log': info.get('log'), 'message': 'refusing to fix while gh3_build appears to be running', }] report.summary = f"build active (pid={info.get('pid')}); fix skipped" return report new_findings: List[dict] = [] preclean_done = False preclean_stats: dict = {} for f in report.findings: kind = f.get('kind') if kind == 'merge_failure': if not preclean_done: try: preclean_stats = gh3builder.preclean_merge_failures(tmp_dir) preclean_done = True except Exception as e: new_findings.append({ **f, 'fix_error': f"{type(e).__name__}: {e}", }) continue new_findings.append({ **f, 'action': 'preclean_merge_failures', 'preclean_stats': preclean_stats, }) elif kind == 'granule_failures': new_findings.append({ **f, 'action': 'reported_only', 'recommendation': ( 'gh3_update --recover-missing-vars ' f"(failure_kind={f.get('failure_kind')})" ), }) elif kind == 'progress_manifest_drift': new_findings.append({ **f, 'action': 'reported_only', 'recommendation': 'gh3_doctor --fix metadata', }) else: new_findings.append(f) report.applied = True report.findings = new_findings n_errors = sum(1 for x in new_findings if 'fix_error' in x) if n_errors: report.severity = Severity.ERROR report.summary = f"{report.summary} | {n_errors} fix error(s)" else: # WARN remains when granule_failures / drift are reported-only; # downgrade to INFO only when every finding had an action that # actually resolved something on disk. any_unresolved = any( x.get('action') == 'reported_only' for x in new_findings ) if preclean_done and not any_unresolved: report.severity = Severity.INFO report.summary = ( f"precleaned {preclean_stats.get('partitions_cleaned', 0)} partitions: " f"{preclean_stats.get('parquets_removed', 0)} parquets, " f"{preclean_stats.get('tmps_removed', 0)} tmps removed" ) elif preclean_done: report.severity = Severity.WARN report.summary = ( f"precleaned {preclean_stats.get('partitions_cleaned', 0)} partitions; " f"granule/drift findings reported for follow-up" ) return report
register( 'tmp_partitions_health', 'tmp/partitions/ post-build forensics (merge failures + granule failures + progress drift)', scope='global', fix=tmp_partitions_health_fix, )(tmp_partitions_health_check)