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

Code before:
                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(list(updated_hosts), ['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 zip(*args):
            with transaction.atomic():
                HostMetric.objects.bulk_create([HostMetric(hostname=hostname, last_automation=current_time) for hostname in hosts], ignore_conflicts=True)
                HostMetric.objects.filter(hostname__in=hosts).update(
                    last_automation=current_time, automated_counter=models.F('automated_counter') + 1, deleted=False
                )

    @property
    def job_verbosity(self):
        return self.job.verbosity


class UnpartitionedJobEvent(JobEvent):
    class Meta:
        proxy = True


UnpartitionedJobEvent._meta.db_table = '_unpartitioned_' + JobEvent._meta.db_table  # noqa


class ProjectUpdateEvent(BasePlaybookEvent):
    VALID_KEYS = BasePlaybookEvent.VALID_KEYS + ['project_update_id', 'workflow_job_id', 'job_created']
    JOB_REFERENCE = 'project_update_id'

    objects = DeferJobCreatedManager()


Review comment (bug):
What if there are _fewer_ than 500 hosts? This "Hand-rolled" iterator is hard to read, but that's not a blocker. I tried to test the method in shell:

```python
>>> my_list = list(range(70))
>>> args = [iter(my_list)]*200
>>> for sublist in zip(*args):
...   print([f for f in sublist])
...   print([f for f in sublist])
... 
```

In this case, as `200 > 70` (imagine you have 70 hosts, and the batch size is 200), it fails to print anything. It seems that the main `for` loop doesn't process _any_ items.

Additionally, when I tested this with 70 list entries and a batch size of 20, it ended at 59, which is less than 70. I expected it to end at 69.

So both examples are suggesting to me that this method loses the partial batch at the end.

Code after:
                # 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(list(updated_hosts), ['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
                )

    @property
    def job_verbosity(self):
        return self.job.verbosity


class UnpartitionedJobEvent(JobEvent):
    class Meta:
        proxy = True


UnpartitionedJobEvent._meta.db_table = '_unpartitioned_' + JobEvent._meta.db_table  # noqa


class ProjectUpdateEvent(BasePlaybookEvent):
    VALID_KEYS = BasePlaybookEvent.VALID_KEYS + ['project_update_id', 'workflow_job_id', 'job_created']
    JOB_REFERENCE = 'project_update_id'