Repository: python-discord/bot

Issue:
RedisCache: _redis alias is assigned too soon
The `RedisCache` wrapper currently suffers from a bug which can cause an `AttributeError` if a value is looked up before the bot's `redis_session` attribute is set.

### Problem

Let's imagine that a cog using the `RedisCache` schedules a task which will perform a lookup. There is no guarantee that the cache is ready, and so the cache API methods always wait for the ready event to be set.

However, as soon as the cache attribute is accessed, the `__get__` method assigns the `self._redis` attribute:
https://github.com/python-discord/bot/blob/239fa5f43ab79435151657671ebcf21eac706fc6/bot/utils/redis_cache.py#L226-L230

This can happen before the cache is ready, and so there is no guarantee that `self.bot.redis_session` is not `None`.

It defaults to `None` here:
https://github.com/python-discord/bot/blob/239fa5f43ab79435151657671ebcf21eac706fc6/bot/bot.py#L34

And only here will the attribute be set:
https://github.com/python-discord/bot/blob/239fa5f43ab79435151657671ebcf21eac706fc6/bot/bot.py#L76-L83

At this point, the cache will report ready status, but it's `self._redis` attribute has already been assigned the `None`.

Since the cache is now ready, the lookup will proceed, and fail with the following error:
```python
2020-08-05 12:31:15 | bot.cogs.verification | ERROR | Failed to get message
Traceback (most recent call last):
  File "/bot/bot/cogs/verification.py", line 325, in _before_first_ping
    last_reminder: t.Optional[int] = await self.reminder_cache.get("last_reminder")
  File "/bot/bot/utils/redis_cache.py", line 263, in get
    value = await self._redis.hget(self._namespace, key)
AttributeError: 'NoneType' object has no attribute 'hget'
```

### Solution

The solution seems to be fairly trivial: the `self._redis` alias simply cannot be safely set before the cache is ready, and we therefore have to lookup the `redis_session` instance via the `bot` attribute.

For example, instead of:
```python
await self._redis.redismethod(...)
```
we should have:
```python
await self.bot.redis_session.redismethod(...)
```
This delays the `redis_session` resolution until after the cache is ready. The `self._redis` alias should probably be removed.


Discussion:
Makes sense to me. Let's do it.

Resolving patch:
diff --git a/bot/utils/redis_cache.py b/bot/utils/redis_cache.py
--- a/bot/utils/redis_cache.py
+++ b/bot/utils/redis_cache.py
@@ -226,7 +226,6 @@ def __get__(self, instance: RedisCache, owner: Any) -> RedisCache:
         for attribute in vars(instance).values():
             if isinstance(attribute, Bot):
                 self.bot = attribute
-                self._redis = self.bot.redis_session
                 return self
         else:
             error_message = (
@@ -251,7 +250,7 @@ async def set(self, key: RedisKeyType, value: RedisValueType) -> None:
         value = self._value_to_typestring(value)
 
         log.trace(f"Setting {key} to {value}.")
-        await self._redis.hset(self._namespace, key, value)
+        await self.bot.redis_session.hset(self._namespace, key, value)
 
     async def get(self, key: RedisKeyType, default: Optional[RedisValueType] = None) -> Optional[RedisValueType]:
         """Get an item from the Redis cache."""
@@ -259,7 +258,7 @@ async def get(self, key: RedisKeyType, default: Optional[RedisValueType] = None)
         key = self._key_to_typestring(key)
 
         log.trace(f"Attempting to retrieve {key}.")
-        value = await self._redis.hget(self._namespace, key)
+        value = await self.bot.redis_session.hget(self._namespace, key)
 
         if value is None:
             log.trace(f"Value not found, returning default value {default}")
@@ -281,7 +280,7 @@ async def delete(self, key: RedisKeyType) -> None:
         key = self._key_to_typestring(key)
 
         log.trace(f"Attempting to delete {key}.")
-        return await self._redis.hdel(self._namespace, key)
+        return await self.bot.redis_session.hdel(self._namespace, key)
 
     async def contains(self, key: RedisKeyType) -> bool:
         """
@@ -291,7 +290,7 @@ async def contains(self, key: RedisKeyType) -> bool:
         """
         await self._validate_cache()
         key = self._key_to_typestring(key)
-        exists = await self._redis.hexists(self._namespace, key)
+        exists = await self.bot.redis_session.hexists(self._namespace, key)
 
         log.trace(f"Testing if {key} exists in the RedisCache - Result is {exists}")
         return exists
@@ -314,7 +313,7 @@ async def items(self) -> ItemsView:
         """
         await self._validate_cache()
         items = self._dict_from_typestring(
-            await self._redis.hgetall(self._namespace)
+            await self.bot.redis_session.hgetall(self._namespace)
         ).items()
 
         log.trace(f"Retrieving all key/value pairs from cache, total of {len(items)} items.")
@@ -323,7 +322,7 @@ async def items(self) -> ItemsView:
     async def length(self) -> int:
         """Return the number of items in the Redis cache."""
         await self._validate_cache()
-        number_of_items = await self._redis.hlen(self._namespace)
+        number_of_items = await self.bot.redis_session.hlen(self._namespace)
         log.trace(f"Returning length. Result is {number_of_items}.")
         return number_of_items
 
@@ -335,7 +334,7 @@ async def clear(self) -> None:
         """Deletes the entire hash from the Redis cache."""
         await self._validate_cache()
         log.trace("Clearing the cache of all key/value pairs.")
-        await self._redis.delete(self._namespace)
+        await self.bot.redis_session.delete(self._namespace)
 
     async def pop(self, key: RedisKeyType, default: Optional[RedisValueType] = None) -> RedisValueType:
         """Get the item, remove it from the cache, and provide a default if not found."""
@@ -364,7 +363,7 @@ async def update(self, items: Dict[RedisKeyType, RedisValueType]) -> None:
         """
         await self._validate_cache()
         log.trace(f"Updating the cache with the following items:\n{items}")
-        await self._redis.hmset_dict(self._namespace, self._dict_to_typestring(items))
+        await self.bot.redis_session.hmset_dict(self._namespace, self._dict_to_typestring(items))
 
     async def increment(self, key: RedisKeyType, amount: Optional[int, float] = 1) -> None:
         """


Test patch: