Repository: ansible/awx
File: awx/main/models/events.py (Python)

Code before:
                )
                summary.failed = bool(summary.dark or summary.failures)
                summaries[(host_id, host)] = summary

                # do not count dark / unreachable hosts as updated
                if not bool(summary.dark):
                    updated_hosts_list.append(host.lower())
                else:
                    logger.warning(f'host {host.lower()} is dark / unreachable, not marking it as updated')

            JobHostSummary.objects.bulk_create(summaries.values())

            # update the last_job_id and last_job_host_summary_id
            # in single queries
            host_mapping = dict((summary['host_id'], summary['id']) for summary in JobHostSummary.objects.filter(job_id=job.id).values('id', 'host_id'))
            updated_hosts = set()
            for h in all_hosts:
                # if the hostname *shows up* in the playbook_on_stats event
                if h.name in hostnames:
                    h.last_job_id = job.id
                    updated_hosts.add(h)
                if h.id in host_mapping:
                    h.last_job_host_summary_id = host_mapping[h.id]
                    updated_hosts.add(h)

            Host.objects.bulk_update(sorted(updated_hosts, key=lambda host: host.id), ['last_job_id', 'last_job_host_summary_id'], batch_size=100)

            # Create/update Host Metrics
            self._update_host_metrics(updated_hosts_list)

    @staticmethod
    def _update_host_metrics(updated_hosts_list):
        from awx.main.models import HostMetric  # circular import

        current_time = now()

        # FUTURE:
        #   - Hand-rolled implementation of itertools.batched(), introduced in Python 3.12.  Replace.
        #   - Ability to do ORM upserts *may* have been introduced in Django 5.0.
        #     See the entry about `create_defaults` in https://docs.djangoproject.com/en/5.0/releases/5.0/#models.
        #     Hopefully this will be fully ready for batch use by 5.2 LTS.

        args = [iter(updated_hosts_list)] * 500
        for hosts in itertools.zip_longest(*args):
            with transaction.atomic():
                HostMetric.objects.bulk_create(
                    [HostMetric(hostname=hostname, last_automation=current_time) for hostname in hosts if hostname is not None], ignore_conflicts=True
                )
                HostMetric.objects.filter(hostname__in=hosts).update(
                    last_automation=current_time, automated_counter=models.F('automated_counter') + 1, deleted=False
                )

Review comment (bug):
For longer-term maintainability, it might be nice to introduce a utility method. Even if the method seems trivial (a simple `sorted` action), it would help stitch these two pieces of code together, and establish the expectation for the future.

And actually, I might even use that here:

https://github.com/ansible/awx/blob/db6e8b9bad660bdc028f48e8461e6794b6c3cef1/awx/main/tasks/host_metrics.py#L150

That might not _currently_ risk contention with other queries, but it is a bulk_update with a batch_size. Seems like there may be a good argument that _all_ such queries in the app should use consistent ordering, because it completely fixes the Deadlock issue.

That would also allow re-using that method in the test here.

Code after:
                    created=now(), modified=now(), job_id=job.id, host_id=host_id, constructed_host_id=constructed_host_id, host_name=host, **host_stats
                )
                summary.failed = bool(summary.dark or summary.failures)
                summaries[(host_id, host)] = summary

                # do not count dark / unreachable hosts as updated
                if not bool(summary.dark):
                    updated_hosts_list.append(host.lower())
                else:
                    logger.warning(f'host {host.lower()} is dark / unreachable, not marking it as updated')

            JobHostSummary.objects.bulk_create(summaries.values())

            # update the last_job_id and last_job_host_summary_id
            # in single queries
            host_mapping = dict((summary['host_id'], summary['id']) for summary in JobHostSummary.objects.filter(job_id=job.id).values('id', 'host_id'))
            updated_hosts = set()
            for h in all_hosts:
                # if the hostname *shows up* in the playbook_on_stats event
                if h.name in hostnames:
                    h.last_job_id = job.id
                    updated_hosts.add(h)
                if h.id in host_mapping:
                    h.last_job_host_summary_id = host_mapping[h.id]
                    updated_hosts.add(h)

            bulk_update_sorted_by_id(Host, updated_hosts, ['last_job_id', 'last_job_host_summary_id'])

            # Create/update Host Metrics
            self._update_host_metrics(updated_hosts_list)

    @staticmethod
    def _update_host_metrics(updated_hosts_list):
        from awx.main.models import HostMetric  # circular import

        current_time = now()

        # FUTURE:
        #   - Hand-rolled implementation of itertools.batched(), introduced in Python 3.12.  Replace.
        #   - Ability to do ORM upserts *may* have been introduced in Django 5.0.
        #     See the entry about `create_defaults` in https://docs.djangoproject.com/en/5.0/releases/5.0/#models.
        #     Hopefully this will be fully ready for batch use by 5.2 LTS.

        args = [iter(updated_hosts_list)] * 500
        for hosts in itertools.zip_longest(*args):
            with transaction.atomic():
                HostMetric.objects.bulk_create(
                    [HostMetric(hostname=hostname, last_automation=current_time) for hostname in hosts if hostname is not None], ignore_conflicts=True
                )
                HostMetric.objects.filter(hostname__in=hosts).update(
                    last_automation=current_time, automated_counter=models.F('automated_counter') + 1, deleted=False