Source code for gedih3.doctor.diagnoses.orphans

# 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

"""orphans diagnosis — leftover temp files and empty partition directories.

Detects:
  - ``*.tmp``, ``*.join.tmp``, ``*.fill.tmp``, ``*.dedup.tmp`` under db / tmp roots.
  - ``_s3_download/`` and ``dask-worker-space/`` directories left behind by
    crashed builds.
  - Empty ``h3_*/`` partition directories (no parquet files).
  - Empty year subdirs.

A finding is only flagged when the file/dir's mtime is older than
``--orphan-age-hours`` (default 24h) so an in-progress build isn't disturbed.

Performance pillar (v0.8.x lessons backport):
  * The temp-file/leftover-dir scan over the database root is pushed
    to workers via :func:`gedih3.doctor.parallel.parallel_map` — one
    task per ``h3_*`` partition. Mirrors the build merge phase's
    ``_list_year_subdirs`` pattern (gh3builder.py:906-924) which
    eliminated the same driver-side recursive walk on continental builds.
  * Empty-partition / empty-year-dir checks are O(1) ``os.scandir``
    instead of ``glob.glob('**/*.parquet', recursive=True)`` (matches
    the build pipeline's emptiness check at gh3builder.py:1356-1362).
  * The temp directory's scan stays driver-side because it's narrow
    and not nested as deeply as the database tree.
"""

from __future__ import annotations

import glob
import os
import shutil
import time
from typing import List

from typing import Optional

from ..report import Report, DoctorContext, Severity
from ..runner import register
from ..parallel import parallel_map, partition_is_empty, list_year_dirs, year_dir_is_empty
from ..fused import register_scan


_TMP_PATTERNS = ('*.tmp', '*.join.tmp', '*.fill.tmp', '*.dedup.tmp')
_LEFTOVER_DIRS = ('_s3_download', 'dask-worker-space')


def _scan_partition_orphans(partition_dir: str, age_seconds: float) -> List[dict]:
    """Worker: list orphan temp files / leftover dirs under one partition.

    Uses local recursive globs *bounded to the partition subtree*; there's
    no driver-side aggregation, and the scope is small enough that one
    glob per partition per pattern is fine. The point is the parallel
    fan-out across partitions, not eliminating recursion within one.
    """
    now = time.time()
    found: List[dict] = []

    for pat in _TMP_PATTERNS:
        for f in glob.glob(os.path.join(partition_dir, '**', pat), recursive=True):
            try:
                age = now - os.path.getmtime(f)
            except OSError:
                continue
            if age >= age_seconds:
                found.append({'path': f, 'age_seconds': int(age), 'kind': 'temp_file'})

    for leftover in _LEFTOVER_DIRS:
        for d in glob.glob(os.path.join(partition_dir, '**', leftover), recursive=True):
            try:
                age = now - os.path.getmtime(d)
            except OSError:
                continue
            if age >= age_seconds:
                found.append({'path': d, 'age_seconds': int(age), 'kind': 'leftover_dir'})

    return found


def _scan_root_orphans(root: str, age_seconds: float) -> List[dict]:
    """Driver-side scan for narrow auxiliary roots (typically the tmp_dir).

    Only used for paths *outside* the partition tree (e.g. the user's
    configured tmp directory). The h3_dir's per-partition scan runs
    through :func:`parallel_map` instead, which keeps the metadata-server
    load distributed.
    """
    if not root or not os.path.isdir(root):
        return []
    now = time.time()
    seen: set = set()
    found: List[dict] = []

    for pat in _TMP_PATTERNS:
        for f in glob.glob(os.path.join(root, '**', pat), recursive=True):
            if f in seen:
                continue
            seen.add(f)
            try:
                age = now - os.path.getmtime(f)
            except OSError:
                continue
            if age >= age_seconds:
                found.append({'path': f, 'age_seconds': int(age), 'kind': 'temp_file'})

    for leftover in _LEFTOVER_DIRS:
        for d in glob.glob(os.path.join(root, '**', leftover), recursive=True):
            if d in seen:
                continue
            seen.add(d)
            try:
                age = now - os.path.getmtime(d)
            except OSError:
                continue
            if age >= age_seconds:
                found.append({'path': d, 'age_seconds': int(age), 'kind': 'leftover_dir'})

    return found


def _empty_check_partition(partition_dir: str) -> List[dict]:
    """Worker: O(1) emptiness checks for one partition + its year subdirs."""
    findings: List[dict] = []
    if partition_is_empty(partition_dir):
        findings.append({'path': partition_dir, 'kind': 'empty_partition'})
        # If the partition itself is empty there are no year subdirs to enumerate.
        return findings
    for year_dir in list_year_dirs(partition_dir):
        if year_dir_is_empty(year_dir):
            findings.append({'path': year_dir, 'kind': 'empty_year_dir'})
    return findings


def _scan_partition_orphans_combined(
    partition_dir: str,
    *,
    age_seconds: float,
    shared: Optional[dict] = None,
) -> List[dict]:
    """Fused worker: runs both the tmp/leftover scan and the emptiness
    check in one task per partition, returning the merged finding list.

    ``shared`` is accepted for fused-dispatch API symmetry but not
    consumed — orphans' file/dir globs are recursive within the partition
    subtree and don't overlap with the parquet listing other diagnoses
    cache.
    """
    findings = _scan_partition_orphans(partition_dir, age_seconds)
    findings.extend(_empty_check_partition(partition_dir))
    return findings


register_scan('orphans', _scan_partition_orphans_combined)


def _finalize_orphans_check(
    ctx: DoctorContext,
    per_partition: dict,
    age_seconds: float,
) -> Report:
    """Driver-side aggregation: per-partition findings + tmp_dir scan + dedupe."""
    findings: List[dict] = []
    seen_paths: set = set()

    def _add(items):
        for it in items:
            p = it.get('path')
            if p in seen_paths:
                continue
            seen_paths.add(p)
            findings.append(it)

    for part_dir, result in per_partition.items():
        if isinstance(result, Exception):
            continue
        _add(result)

    # Auxiliary tmp_dir (narrow, driver-side scan is fine).
    _add(_scan_root_orphans(ctx.tmp_dir, age_seconds))

    n_files = sum(1 for f in findings if f['kind'] == 'temp_file')
    n_dirs = sum(1 for f in findings if f['kind'] == 'leftover_dir')
    n_empty = sum(1 for f in findings if f['kind'] in ('empty_partition', 'empty_year_dir'))

    summary = f"{n_files} temp files, {n_dirs} leftover dirs, {n_empty} empty partition/year dirs"
    severity = Severity.INFO if not findings else Severity.WARN
    return Report(name='orphans', severity=severity, findings=findings, summary=summary)


[docs] def orphans_check(ctx: DoctorContext) -> Report: age_hours = getattr(ctx.args, 'orphan_age_hours', 24.0) age_seconds = age_hours * 3600 args = getattr(ctx, 'args', None) per_partition: dict = {} for part_dir, result in parallel_map( ctx.partition_dirs, _scan_partition_orphans_combined, args=args, desc='orphans: scanning partitions', unit='part', age_seconds=age_seconds, ): per_partition[part_dir] = result return _finalize_orphans_check(ctx, per_partition, age_seconds)
[docs] def orphans_fix(ctx: DoctorContext, report: Report) -> Report: fixed = [] for f in report.findings: path = f['path'] kind = f['kind'] try: if kind in ('temp_file',): if os.path.isfile(path): os.unlink(path) fixed.append({**f, 'action': 'deleted'}) elif kind in ('leftover_dir', 'empty_partition', 'empty_year_dir'): if os.path.isdir(path): shutil.rmtree(path, ignore_errors=False) fixed.append({**f, 'action': 'removed'}) except Exception as e: fixed.append({**f, 'fix_error': f"{type(e).__name__}: {e}"}) report.applied = True report.findings = fixed n_errors = sum(1 for x in fixed if 'fix_error' in x) if n_errors: report.severity = Severity.ERROR report.summary = f"cleaned {len(fixed) - n_errors}; failed {n_errors}" else: report.severity = Severity.INFO report.summary = f"cleaned {len(fixed)} orphan items" # If any partition dirs were removed, prune them from ctx so subsequent # diagnoses don't try to inspect them. removed_partitions = {x['path'].rstrip('/').rstrip(os.sep) for x in fixed if x.get('kind') == 'empty_partition'} if removed_partitions: ctx.partition_dirs = [d for d in ctx.partition_dirs if d.rstrip('/').rstrip(os.sep) not in removed_partitions] # R2 producer-driven refresh: if any partition dirs or files were # removed, regenerate the H3 manifest so future consumers don't # see ghost entries pointing at deleted paths. removed_anything = any( x.get('action') in ('deleted', 'removed') for x in fixed ) if removed_anything: from ...utils import generate_manifest try: generate_manifest(ctx.h3_dir, tree_shape='h3db') except Exception as e: # Non-fatal: log but don't fail the fix report. The smoke # check at next consumer entry will surface the stale state # loudly. from ...logging_config import get_logger get_logger(__name__).warning( f"orphans_fix: manifest regeneration failed: " f"{type(e).__name__}: {e}" ) return report
register('orphans', 'leftover temp files + empty partition dirs', scope='global', fix=orphans_fix)(orphans_check)