Repository: mozilla/bugbug

Issue:
Invalidate cache when a bug changes
Otherwise we risk making an obsolete prediction for a bug.


Discussion:

Resolving patch:
diff --git a/http_service/app.py b/http_service/app.py
--- a/http_service/app.py
+++ b/http_service/app.py
@@ -21,7 +21,8 @@
 
 from bugbug import get_bugbug_version
 
-from .models import MODELS_NAMES, classify_bug
+from .models import MODELS_NAMES, change_time_key, classify_bug, result_key
+from .utils import get_bugzilla_http_client
 
 API_TOKEN = "X-Api-Key"
 
@@ -51,6 +52,9 @@
 
 BUGZILLA_TOKEN = os.environ.get("BUGBUG_BUGZILLA_TOKEN")
 
+# Keep an HTTP client around for persistent connections
+BUGBUG_HTTP_CLIENT, BUGZILLA_API_URL = get_bugzilla_http_client()
+
 logging.basicConfig(level=logging.INFO)
 LOGGER = logging.getLogger()
 
@@ -100,8 +104,6 @@ def schedule_bug_classification(model_name, bug_ids):
 
     job_id = get_job_id()
 
-    print("Scheduling", bug_ids)
-
     # Set the mapping before queuing to avoid some race conditions
     job_id_mapping = {get_mapping_key(model_name, bug_id): job_id for bug_id in bug_ids}
     redis_conn.mset(job_id_mapping)
@@ -126,12 +128,54 @@ def is_running(model_name, bug_id):
 
     job_status = job.get_status()
     if job_status in ("running", "started", "queued"):
-        print("Bug classification already running", model_name, bug_id)
         return True
 
     return False
 
 
+def get_bugs_last_change_time(bug_ids):
+    query = {"id": bug_ids, "include_fields": ["last_change_time", "id"]}
+    header = {"X-Bugzilla-API-Key": "", "User-Agent": "bugbug"}
+    response = BUGBUG_HTTP_CLIENT.get(
+        BUGZILLA_API_URL, params=query, headers=header, verify=True, timeout=30
+    )
+    response.raise_for_status()
+
+    raw_bugs = response.json()
+
+    bugs = {}
+
+    for bug in raw_bugs["bugs"]:
+        bugs[bug["id"]] = bug["last_change_time"]
+
+    return bugs
+
+
+def is_prediction_invalidated(model_name, bug_id, change_time):
+    # First get the saved change time
+    change_key = change_time_key(model_name, bug_id)
+
+    saved_change_time = redis_conn.get(change_key)
+
+    # If we have no last changed time, the bug was not classified yet or the bug was classified by an old worker
+    if not saved_change_time:
+        # We can have a result without a cache time
+        if redis_conn.exists(result_key(model_name, bug_id)):
+            return True
+
+        return False
+
+    return saved_change_time.decode("utf-8") != change_time
+
+
+def clean_prediction_cache(model_name, bug_id):
+    # If the bug was modified since last time we classified it, clear the cache to avoid stale answer
+    LOGGER.debug("Cleaning results for bug id %s and model %s", bug_id, model_name)
+
+    redis_conn.delete(result_key(model_name, bug_id))
+    redis_conn.delete(change_time_key(model_name, bug_id))
+
+
 def get_bug_classification(model_name, bug_id):
     redis_key = f"result_{model_name}_{bug_id}"
     result = redis_conn.get(redis_key)
@@ -191,6 +235,12 @@ def model_prediction(model_name, bug_id):
     else:
         LOGGER.info("Request with API TOKEN %r", auth)
 
+    # Get the latest change from Bugzilla for the bug
+    bug = get_bugs_last_change_time([bug_id], auth)
+
+    if is_prediction_invalidated(model_name, bug_id, bug[bug_id]):
+        clean_prediction_cache(model_name, bug_id)
+
     status_code = 200
     data = get_bug_classification(model_name, bug_id)
 
@@ -335,16 +385,23 @@ def batch_prediction(model_name):
     data = {}
     missing_bugs = []
 
-    for bug in bugs:
-        data[str(bug)] = get_bug_classification(model_name, bug)
-        if not data[str(bug)]:
-            if not is_running(model_name, bug):
-                missing_bugs.append(bug)
+    bug_change_dates = get_bugs_last_change_time(bugs)
+
+    for bug_id in bugs:
+
+        change_time = bug_change_dates.get(int(bug_id), None)
+        # Change time could be None if it's a security bug
+        if change_time and is_prediction_invalidated(model_name, bug_id, change_time):
+            clean_prediction_cache(model_name, bug_id)
+
+        data[str(bug_id)] = get_bug_classification(model_name, bug_id)
+        if not data[str(bug_id)]:
+            if not is_running(model_name, bug_id):
+                missing_bugs.append(bug_id)
             status_code = 202
-            data[str(bug)] = {"ready": False}
+            data[str(bug_id)] = {"ready": False}
 
     if missing_bugs:
-        print("Scheduling call for missing bugs")
         # TODO: We should probably schedule chunks of bugs to avoid jobs that
         # are running for too long and reduce pressure on bugzilla, it mights
         # not like getting 1 million bug at a time
diff --git a/http_service/models.py b/http_service/models.py
--- a/http_service/models.py
+++ b/http_service/models.py
@@ -27,6 +27,14 @@
 MODEL_CACHE = {}
 
 
+def result_key(model_name, bug_id):
+    return f"result_{model_name}_{bug_id}"
+
+
+def change_time_key(model_name, bug_id):
+    return f"bugbug:change_time_{model_name}_{bug_id}"
+
+
 def get_model(model_name):
     if model_name not in MODEL_CACHE:
         print("Recreating the model in cache")
@@ -131,9 +139,13 @@ def classify_bug(
 
         encoded_data = json.dumps(data)
 
-        redis_key = f"result_{model_name}_{bug_id}"
+        redis_key = result_key(model_name, bug_id)
 
         redis.set(redis_key, encoded_data)
         redis.expire(redis_key, expiration)
 
+        # Save the bug last change
+        change_key = change_time_key(model_name, bug_id)
+        redis.set(change_key, bugs[bug_id]["last_change_time"])
+
     return "OK"
diff --git a/http_service/utils.py b/http_service/utils.py
new file mode 100644
--- /dev/null
+++ b/http_service/utils.py
@@ -0,0 +1,24 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+
+import requests
+from libmozdata import config
+from requests.adapters import HTTPAdapter
+from requests.packages.urllib3.util.retry import Retry
+
+
+def get_bugzilla_http_client():
+    """ Copied from libmozdata.connection.Connection and libmozdata.bugzilla.Bugzilla
+    """
+    http_client = requests.Session()
+    status_forcelist = [429]
+    retries = Retry(total=256, backoff_factor=1, status_forcelist=status_forcelist)
+    bugzilla_url = config.get("Bugzilla", "URL", "https://bugzilla.mozilla.org")
+    bugzilla_api_url = bugzilla_url + "/rest/bug"
+    http_client.mount(bugzilla_url, HTTPAdapter(max_retries=retries))
+
+    return http_client, bugzilla_api_url


Test patch: