Repository: learningequality/kolibri

Issue:
Only trigger Zeroconf update if the instance data or URL differs
<!--
Instructions:
 * Fill out the sections below, replace …'s with information about your issue
 * Use the 'preview' function above this text box to verify formatting before submitting
-->

## Observed behavior
<!--
Description of the behavior that was observed, including screenshots or other references when applicable
-->
Having Kolibri listen for other instance's Zeroconf broadcast updates, in addition to join and leave events, is important for handling use cases where Kolibri's broadcast may change. It appears that on Android at the least, Zeroconf triggers a join and update simultaneously. This has repercussions on the associated handling of those events, like testing a device connection. While the discovery tasks use deterministic job IDs, it appears that this does not prevent multiple jobs from running at once.

## Errors and logs
<!--
Relevant logs from:
 * the command line
 * ~/.kolibri/logs/kolibri.txt
 * the browser console

Please wrap errors in triple backticks for clean formatting like this:
```
01:10 info: something happened
01:12 error: something bad happened
```
-->
```
INFO 2023-09-11 15:37:22,548 kolibri.core.discovery.utils.network.broadcast Kolibri instance '8079fe4b39cabf179447184e05b4130e' joined zeroconf network; device info: {'application': 'kolibri', 'kolibri_version'>
INFO 2023-09-11 15:37:22,586 kolibri.core.discovery.utils.network.broadcast Kolibri instance '8079fe4b39cabf179447184e05b4130e' updated zeroconf network; device info: {'application': 'kolibri', 'kolibri_version>
```

## Expected behavior
<!--
Description of what behavior was expected but did not occur
-->
We don't trigger an update so soon after an instance is seen as joining. If the update is legitimate, the instance will eventually receive the proper update later (should be at most 60 seconds)

### Developer recommendation
The Zeroconf broadcast has an [in-memory cache](https://github.com/learningequality/kolibri/blob/a90ccd8b9fb7213b956570aedb4e6d6371bfde4c/kolibri/core/discovery/utils/network/broadcast.py#L346) of the discovered instances. Adding a new field to the [instances](https://github.com/learningequality/kolibri/blob/a90ccd8b9fb7213b956570aedb4e6d6371bfde4c/kolibri/core/discovery/utils/network/broadcast.py#L68) for tracking the epoch/time of the last Zeroconf event, e.g. `last_seen`, would allow the [update handler](https://github.com/learningequality/kolibri/blob/a90ccd8b9fb7213b956570aedb4e6d6371bfde4c/kolibri/core/discovery/utils/network/broadcast.py#L565-L588) to [publish an update event](https://github.com/learningequality/kolibri/blob/a90ccd8b9fb7213b956570aedb4e6d6371bfde4c/kolibri/core/discovery/utils/network/broadcast.py#L588) if and only if:
- the data on the instance has changed with regards to the copy in broadcast's cache, or
- the interval between the current time and the `last_seen` time is greater than the TTL

## User-facing consequences
<!--
Implications and real-world consequences for learners, coaches, admins, and other users of the application
-->
Battery drain from too much processing by the Kolibri app

## Context
<!--
Tell us about your environment, including:
 * Kolibri version
 * Operating system
 * Browser
-->
Kolibri 0.16
Android


Discussion:

Resolving patch:
diff --git a/kolibri/core/discovery/utils/network/broadcast.py b/kolibri/core/discovery/utils/network/broadcast.py
--- a/kolibri/core/discovery/utils/network/broadcast.py
+++ b/kolibri/core/discovery/utils/network/broadcast.py
@@ -1,6 +1,7 @@
 import json
 import logging
 import socket
+import time
 import uuid
 
 from magicbus.base import Bus
@@ -80,6 +81,7 @@ class KolibriInstance(object):
         "device_info",
         "service_info",
         "prefix",
+        "last_seen",
     )
 
     def __init__(
@@ -99,6 +101,22 @@ def __init__(
         self.is_self = False
         self.service_info = None
         self.prefix = prefix
+        self.last_seen = time.time()
+
+    def __eq__(self, other):
+        if self.id != other.id:
+            return False
+        if self.ip != other.ip:
+            return False
+        if self.port != other.port:
+            return False
+        if self.host != other.host:
+            return False
+        if self.device_info != other.device_info:
+            return False
+        if self.prefix != other.prefix:
+            return False
+        return True
 
     @property
     def name(self):
@@ -580,6 +598,16 @@ def update_service(self, name):
 
         instance = self._build_instance(service_info)
         if not instance.is_self:
+            if name in self.other_instances:
+                # if we already have the instance in our cache, we should check to
+                # see if we actually have any updated information. If not, we can
+                # just ignore the update
+                current_instance = self.other_instances[name]
+                if (
+                    current_instance == instance
+                    and instance.last_seen - current_instance.last_seen < SERVICE_TTL
+                ):
+                    return
             self.other_instances[name] = instance
             logger.info(
                 "Kolibri instance '%s' updated zeroconf network; device info: %s"


Test patch:
diff --git a/kolibri/core/discovery/test/test_network_broadcast.py b/kolibri/core/discovery/test/test_network_broadcast.py
--- a/kolibri/core/discovery/test/test_network_broadcast.py
+++ b/kolibri/core/discovery/test/test_network_broadcast.py
@@ -15,6 +15,7 @@
 from ..utils.network.broadcast import LOCAL_DOMAIN
 from ..utils.network.broadcast import LOCAL_EVENTS
 from ..utils.network.broadcast import NETWORK_EVENTS
+from ..utils.network.broadcast import SERVICE_TTL
 from ..utils.network.broadcast import SERVICE_TYPE
 
 MOCK_INTERFACE_IP = "111.222.111.222"
@@ -553,6 +554,193 @@ def test_update_service__is_self(
         mock_logger.assert_not_called()
         self.listener.mock.update_instance.assert_not_called()
 
+    @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance")
+    @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info")
+    @mock.patch(BROADCAST_MODULE + "logger.info")
+    def test_update_service__no_change_less_than_TTL(
+        self, mock_logger, mock_get_service_info, mock_build_instance
+    ):
+        service_info = mock.Mock(spec_set=ServiceInfo)("test")
+        mock_get_service_info.return_value = service_info
+        original_instance = KolibriInstance(
+            MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT
+        )
+        expected_instance = KolibriInstance(
+            MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT
+        )
+        mock_build_instance.return_value = expected_instance
+        self.broadcast.other_instances["test"] = original_instance
+        self.broadcast.update_service("test")
+        self.assertEqual(expected_instance, self.broadcast.other_instances["test"])
+        mock_get_service_info.assert_called_once_with("test")
+        mock_build_instance.assert_called_once_with(service_info)
+        mock_logger.assert_not_called()
+        self.listener.mock.update_instance.assert_not_called()
+
+    @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance")
+    @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info")
+    @mock.patch(BROADCAST_MODULE + "logger.info")
+    def test_update_service__no_change_more_than_or_equal_to_TTL(
+        self, mock_logger, mock_get_service_info, mock_build_instance
+    ):
+        service_info = mock.Mock(spec_set=ServiceInfo)("test")
+        mock_get_service_info.return_value = service_info
+        original_instance = KolibriInstance(
+            MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT
+        )
+        expected_instance = KolibriInstance(
+            MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT
+        )
+        expected_instance.last_seen = original_instance.last_seen + SERVICE_TTL
+        mock_build_instance.return_value = expected_instance
+        self.broadcast.other_instances["test"] = original_instance
+        self.broadcast.update_service("test")
+        self.assertEqual(expected_instance, self.broadcast.other_instances["test"])
+        mock_get_service_info.assert_called_once_with("test")
+        mock_build_instance.assert_called_once_with(service_info)
+        mock_logger.assert_called_once()
+        self.listener.mock.update_instance.assert_called_once_with(expected_instance)
+
+    @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance")
+    @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info")
+    @mock.patch(BROADCAST_MODULE + "logger.info")
+    def test_update_service__change_id(
+        self, mock_logger, mock_get_service_info, mock_build_instance
+    ):
+        service_info = mock.Mock(spec_set=ServiceInfo)("test")
+        mock_get_service_info.return_value = service_info
+        original_instance = KolibriInstance(
+            "not the same id", ip=MOCK_INTERFACE_IP, port=MOCK_PORT
+        )
+        expected_instance = KolibriInstance(
+            MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT
+        )
+        mock_build_instance.return_value = expected_instance
+        self.broadcast.other_instances["test"] = original_instance
+        self.broadcast.update_service("test")
+        self.assertEqual(expected_instance, self.broadcast.other_instances["test"])
+        mock_get_service_info.assert_called_once_with("test")
+        mock_build_instance.assert_called_once_with(service_info)
+        mock_logger.assert_called_once()
+        self.listener.mock.update_instance.assert_called_once_with(expected_instance)
+
+    @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance")
+    @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info")
+    @mock.patch(BROADCAST_MODULE + "logger.info")
+    def test_update_service__change_ip(
+        self, mock_logger, mock_get_service_info, mock_build_instance
+    ):
+        service_info = mock.Mock(spec_set=ServiceInfo)("test")
+        mock_get_service_info.return_value = service_info
+        original_instance = KolibriInstance(MOCK_ID, ip="211.211.16.1", port=MOCK_PORT)
+        expected_instance = KolibriInstance(
+            MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT
+        )
+        mock_build_instance.return_value = expected_instance
+        self.broadcast.other_instances["test"] = original_instance
+        self.broadcast.update_service("test")
+        self.assertEqual(expected_instance, self.broadcast.other_instances["test"])
+        mock_get_service_info.assert_called_once_with("test")
+        mock_build_instance.assert_called_once_with(service_info)
+        mock_logger.assert_called_once()
+        self.listener.mock.update_instance.assert_called_once_with(expected_instance)
+
+    @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance")
+    @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info")
+    @mock.patch(BROADCAST_MODULE + "logger.info")
+    def test_update_service__change_port(
+        self, mock_logger, mock_get_service_info, mock_build_instance
+    ):
+        service_info = mock.Mock(spec_set=ServiceInfo)("test")
+        mock_get_service_info.return_value = service_info
+        original_instance = KolibriInstance(MOCK_ID, ip=MOCK_INTERFACE_IP, port="2121")
+        expected_instance = KolibriInstance(
+            MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT
+        )
+        mock_build_instance.return_value = expected_instance
+        self.broadcast.other_instances["test"] = original_instance
+        self.broadcast.update_service("test")
+        self.assertEqual(expected_instance, self.broadcast.other_instances["test"])
+        mock_get_service_info.assert_called_once_with("test")
+        mock_build_instance.assert_called_once_with(service_info)
+        mock_logger.assert_called_once()
+        self.listener.mock.update_instance.assert_called_once_with(expected_instance)
+
+    @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance")
+    @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info")
+    @mock.patch(BROADCAST_MODULE + "logger.info")
+    def test_update_service__change_host(
+        self, mock_logger, mock_get_service_info, mock_build_instance
+    ):
+        service_info = mock.Mock(spec_set=ServiceInfo)("test")
+        mock_get_service_info.return_value = service_info
+        original_instance = KolibriInstance(
+            MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT, host="http://test.com"
+        )
+        expected_instance = KolibriInstance(
+            MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT, host="http://test2.com"
+        )
+        mock_build_instance.return_value = expected_instance
+        self.broadcast.other_instances["test"] = original_instance
+        self.broadcast.update_service("test")
+        self.assertEqual(expected_instance, self.broadcast.other_instances["test"])
+        mock_get_service_info.assert_called_once_with("test")
+        mock_build_instance.assert_called_once_with(service_info)
+        mock_logger.assert_called_once()
+        self.listener.mock.update_instance.assert_called_once_with(expected_instance)
+
+    @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance")
+    @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info")
+    @mock.patch(BROADCAST_MODULE + "logger.info")
+    def test_update_service__change_device_info(
+        self, mock_logger, mock_get_service_info, mock_build_instance
+    ):
+        service_info = mock.Mock(spec_set=ServiceInfo)("test")
+        mock_get_service_info.return_value = service_info
+        original_instance = KolibriInstance(
+            MOCK_ID,
+            ip=MOCK_INTERFACE_IP,
+            port=MOCK_PORT,
+            device_info={"kolibri_version": "0.15.12"},
+        )
+        expected_instance = KolibriInstance(
+            MOCK_ID,
+            ip=MOCK_INTERFACE_IP,
+            port=MOCK_PORT,
+            device_info={"kolibri_version": "0.16.0"},
+        )
+        mock_build_instance.return_value = expected_instance
+        self.broadcast.other_instances["test"] = original_instance
+        self.broadcast.update_service("test")
+        self.assertEqual(expected_instance, self.broadcast.other_instances["test"])
+        mock_get_service_info.assert_called_once_with("test")
+        mock_build_instance.assert_called_once_with(service_info)
+        mock_logger.assert_called_once()
+        self.listener.mock.update_instance.assert_called_once_with(expected_instance)
+
+    @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._build_instance")
+    @mock.patch(BROADCAST_MODULE + "KolibriBroadcast._get_service_info")
+    @mock.patch(BROADCAST_MODULE + "logger.info")
+    def test_update_service__change_prefix(
+        self, mock_logger, mock_get_service_info, mock_build_instance
+    ):
+        service_info = mock.Mock(spec_set=ServiceInfo)("test")
+        mock_get_service_info.return_value = service_info
+        original_instance = KolibriInstance(
+            MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT
+        )
+        expected_instance = KolibriInstance(
+            MOCK_ID, ip=MOCK_INTERFACE_IP, port=MOCK_PORT, prefix="/kolibri"
+        )
+        mock_build_instance.return_value = expected_instance
+        self.broadcast.other_instances["test"] = original_instance
+        self.broadcast.update_service("test")
+        self.assertEqual(expected_instance, self.broadcast.other_instances["test"])
+        mock_get_service_info.assert_called_once_with("test")
+        mock_build_instance.assert_called_once_with(service_info)
+        mock_logger.assert_called_once()
+        self.listener.mock.update_instance.assert_called_once_with(expected_instance)
+
     @mock.patch(BROADCAST_MODULE + "logger.info")
     def test_remove_service(self, mock_logger):
         expected_instance = mock.Mock(spec_set=KolibriInstance)(