Repository: zulip/zulip

Issue:
cache: `post_save` signal race condition on `RealmEmoji`
We use the `post_save` to flush the realm emoji cache whenever we call `realm_emoji.save()`. The signal handler `zerver.models.flush_realm_emoji` flushes the cache whenever this signal is triggered. However, the value of the cache can be invalid when the file name is `None`.

To replicate, add an assertion `assert realm_emoji.file_name is not None` in `get_realm_emoji_dicts` before `emoji_url = get_emoji_url(realm_emoji.file_name, realm_emoji.realm_id)`:

```diff
diff --git a/zerver/models.py b/zerver/models.py
index 973f54758a..9dfadbe279 100644
--- a/zerver/models.py
+++ b/zerver/models.py
@@ -1147,6 +1147,7 @@ def get_realm_emoji_dicts(realm: Realm, only_active_emojis: bool = False) -> Dic
         author_id = None
         if realm_emoji.author:
             author_id = realm_emoji.author_id
+        assert realm_emoji.file_name is not None
         emoji_url = get_emoji_url(realm_emoji.file_name, realm_emoji.realm_id)
 
         emoji_dict: EmojiInfo = dict(
```
and then run `./manage.py populate_db`.

You should see an error like this:
```python
2022-07-21 19:15:23.615 INFO [bmemcached.protocol] Flushing memcached
Traceback (most recent call last):
  File "./manage.py", line 152, in <module>
    execute_from_command_line(sys.argv)
  File "./manage.py", line 117, in execute_from_command_line
    utility.execute()
  File "/srv/zulip-py3-venv/lib/python3.8/site-packages/django/core/management/__init__.py", line 440, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
  File "/srv/zulip-py3-venv/lib/python3.8/site-packages/django/core/management/base.py", line 414, in run_from_argv
    self.execute(*args, **cmd_options)
  File "/srv/zulip-py3-venv/lib/python3.8/site-packages/django/core/management/base.py", line 460, in execute
    output = self.handle(*args, **options)
  File "/home/pig208/zulip/zilencer/management/commands/populate_db.py", line 792, in handle
    check_add_realm_emoji(zulip_realm, "green_tick", iago, File(fp))
  File "/home/pig208/zulip/zerver/actions/realm_emoji.py", line 25, in check_add_realm_emoji
    realm_emoji.save()
  File "/srv/zulip-py3-venv/lib/python3.8/site-packages/django/db/models/base.py", line 806, in save
    self.save_base(
  File "/srv/zulip-py3-venv/lib/python3.8/site-packages/django/db/models/base.py", line 872, in save_base
    post_save.send(
  File "/srv/zulip-py3-venv/lib/python3.8/site-packages/django/dispatch/dispatcher.py", line 176, in send
    return [
  File "/srv/zulip-py3-venv/lib/python3.8/site-packages/django/dispatch/dispatcher.py", line 177, in <listcomp>
    (receiver, receiver(signal=self, sender=sender, **named))
  File "/home/pig208/zulip/zerver/models.py", line 1181, in flush_realm_emoji
    get_realm_emoji_cache_key(realm), get_realm_emoji_uncached(realm), timeout=3600 * 24 * 7
  File "/home/pig208/zulip/zerver/models.py", line 1167, in get_realm_emoji_uncached
    return get_realm_emoji_dicts(realm)
  File "/home/pig208/zulip/zerver/models.py", line 1139, in get_realm_emoji_dicts
    assert realm_emoji.file_name is not None 
AssertionError
```


Discussion:
Hello @zulip/server-emoji members, this issue was labeled with the "area: emoji" label, so you may want to check it out!

<!-- areaLabelAddition -->


Resolving patch:
diff --git a/zerver/models.py b/zerver/models.py
--- a/zerver/models.py
+++ b/zerver/models.py
@@ -1111,7 +1111,17 @@ class Meta:
 
 
 def get_realm_emoji_dicts(realm: Realm, only_active_emojis: bool = False) -> Dict[str, EmojiInfo]:
-    query = RealmEmoji.objects.filter(realm=realm).select_related("author")
+    # RealmEmoji objects with file_name=None are still in the process
+    # of being uploaded, and we expect to be cleaned up by a
+    # try/finally block if the upload fails, so it's correct to
+    # exclude them.
+    query = (
+        RealmEmoji.objects.filter(realm=realm)
+        .exclude(
+            file_name=None,
+        )
+        .select_related("author")
+    )
     if only_active_emojis:
         query = query.filter(deactivated=False)
     d = {}
@@ -1121,6 +1131,7 @@ def get_realm_emoji_dicts(realm: Realm, only_active_emojis: bool = False) -> Dic
         author_id = None
         if realm_emoji.author:
             author_id = realm_emoji.author_id
+        assert realm_emoji.file_name is not None
         emoji_url = get_emoji_url(realm_emoji.file_name, realm_emoji.realm_id)
 
         emoji_dict: EmojiInfo = dict(
@@ -1159,6 +1170,16 @@ def get_active_realm_emoji_uncached(realm: Realm) -> Dict[str, EmojiInfo]:
 
 
 def flush_realm_emoji(*, instance: RealmEmoji, **kwargs: object) -> None:
+    if instance.file_name is None:
+        # Because we construct RealmEmoji.file_name using the ID for
+        # the RealmEmoji object, it will always have file_name=None,
+        # and then it'll be updated with the actual filename as soon
+        # as the upload completes successfully.
+        #
+        # Doing nothing when file_name=None is the best option, since
+        # such an object shouldn't have been cached yet, and this
+        # function will be called again when file_name is set.
+        return
     realm = instance.realm
     cache_set(
         get_realm_emoji_cache_key(realm), get_realm_emoji_uncached(realm), timeout=3600 * 24 * 7


Test patch:
diff --git a/zerver/tests/test_realm_emoji.py b/zerver/tests/test_realm_emoji.py
--- a/zerver/tests/test_realm_emoji.py
+++ b/zerver/tests/test_realm_emoji.py
@@ -23,7 +23,7 @@ def create_test_emoji(self, name: str, author: UserProfile) -> RealmEmoji:
         return realm_emoji
 
     def create_test_emoji_with_no_author(self, name: str, realm: Realm) -> RealmEmoji:
-        realm_emoji = RealmEmoji.objects.create(realm=realm, name=name)
+        realm_emoji = RealmEmoji.objects.create(realm=realm, name=name, file_name=name)
         return realm_emoji
 
     def test_list(self) -> None: