Repository: ansible/awx
File: awx/main/tasks/facts.py (Python)

Code before:
@log_excess_runtime(
    logger,
    debug_cutoff=0.01,
    msg='Inventory {inventory_id} host facts: updated {updated_ct}, cleared {cleared_ct}, unchanged {unmodified_ct}, took {delta:.3f} s',
    add_log_data=True,
)
def finish_fact_cache(destination, log_data, job_id=None, inventory_id=None):
    log_data['inventory_id'] = inventory_id
    log_data['updated_ct'] = 0
    log_data['unmodified_ct'] = 0
    log_data['cleared_ct'] = 0

    summary_path = os.path.join(destination, 'host_cache_summary.json')
    if not os.path.exists(summary_path):
        system_tracking_logger.error(f'Missing summary file at {summary_path}')
        return

    try:
        with open(summary_path, 'r', encoding='utf-8') as f:
            summary = json.load(f)
    except Exception:
        system_tracking_logger.error(f'Unable to read summary file at {summary_path}')
        return

    host_names = summary.get('hosts_cached', [])
    facts_write_time = os.path.getmtime(summary_path)

    # Lookup Host objects
    hosts_cached = list(Host.objects.filter(name__in=host_names, inventory_id=inventory_id).order_by('id'))

    hosts_to_update = []
    host_facts_dir = os.path.join(settings.ANSIBLE_FACT_CACHE, str(inventory_id))  # or however the path is built

    for host in hosts_cached:
        filepath = os.path.join(host_facts_dir, host.name)
        if not os.path.realpath(filepath).startswith(host_facts_dir):
            system_tracking_logger.error(f'Invalid path for facts file: {filepath}')
            continue

        if os.path.exists(filepath):
            modified = os.path.getmtime(filepath)
            if not facts_write_time or modified > facts_write_time:
                try:
                    with codecs.open(filepath, 'r', encoding='utf-8') as f:
                        ansible_facts = json.load(f)
                except ValueError:
                    continue

                host.ansible_facts = ansible_facts
                host.ansible_facts_modified = now()
                hosts_to_update.append(host)

Review comment (suggestion):
This should work the same as before. My idea to solve the UTC problem was that we might use `os.path.getmtime` of _this_ file at `summary_path`. That way, if the remove node rewrites the modtime of the files, it does so consistently for the artifact files as well as this summary file.

Code after:


@log_excess_runtime(
    logger,
    debug_cutoff=0.01,
    msg='Inventory {inventory_id} host facts: updated {updated_ct}, cleared {cleared_ct}, unchanged {unmodified_ct}, took {delta:.3f} s',
    add_log_data=True,
)
def finish_fact_cache(artifacts_dir, job_id=None, inventory_id=None, log_data=None):
    log_data = log_data or {}
    log_data['inventory_id'] = inventory_id
    log_data['updated_ct'] = 0
    log_data['unmodified_ct'] = 0
    log_data['cleared_ct'] = 0
    # The summary file is directly inside the artifacts dir
    summary_path = os.path.join(artifacts_dir, 'host_cache_summary.json')
    if not os.path.exists(summary_path):
        logger.error(f'Missing summary file at {summary_path}')
        return

    try:
        with open(summary_path, 'r', encoding='utf-8') as f:
            summary = json.load(f)
        facts_write_time = os.path.getmtime(summary_path)  # After successful read
    except (json.JSONDecodeError, OSError) as e:
        logger.error(f'Error reading summary file at {summary_path}: {e}')
        return

    host_names = summary.get('hosts_cached', [])
    hosts_cached = Host.objects.filter(name__in=host_names).order_by('id').iterator()
    # Path where individual fact files were written
    fact_cache_dir = os.path.join(artifacts_dir, 'fact_cache')
    hosts_to_update = []

    for host in hosts_cached:
        filepath = os.path.join(fact_cache_dir, host.name)
        if not os.path.realpath(filepath).startswith(fact_cache_dir):
            logger.error(f'Invalid path for facts file: {filepath}')
            continue

        if os.path.exists(filepath):
            # If the file changed since we wrote the last facts file, pre-playbook run...
            modified = os.path.getmtime(filepath)
            if not facts_write_time or modified >= facts_write_time:
                try:
                    with codecs.open(filepath, 'r', encoding='utf-8') as f:
                        ansible_facts = json.load(f)
                except ValueError:
                    continue

                if ansible_facts != host.ansible_facts: