Repository: Kraigie/nostrum
Pull request #487 (2023-05-15 21:03:07)

Pluggable state everywhere
Make all of nostrum's state pluggable. This includes internal state, such as ratelimiter buckets, guild shard mappings, and more.


Patch:
diff --git a/guides/State.md b/guides/State.md
index 02e708c9..50663059 100644
--- a/guides/State.md
+++ b/guides/State.md
@@ -56,10 +56,10 @@ There are some use cases where given a `channel_id` or `guild_id` we may want to
 find the `guild` or `shard` that the `id` belongs to. For this purpose Nostrum
 stores these types of relations in ETS tables.
 
-The following mappings exists.
+The following mappings exist:
 ```elixir
 guild_channel_id -> guild_id
 guild_id -> shard_num
 ```
 
-Please see `Nostrum.Cache.Mapping.ChannelGuild` for one such map.
+Please see `Nostrum.Cache.ChannelGuildMapping` for one such map.
diff --git a/lib/nostrum/api/bucket.ex b/lib/nostrum/api/bucket.ex
deleted file mode 100644
index 3b50ade8..00000000
--- a/lib/nostrum/api/bucket.ex
+++ /dev/null
@@ -1,55 +0,0 @@
-defmodule Nostrum.Api.Bucket do
-  @moduledoc false
-
-  alias Nostrum.Util
-
-  def update_bucket(route, remaining, reset_time, latency) do
-    :ets.insert(:ratelimit_buckets, {route, remaining, reset_time, latency})
-  end
-
-  def lookup_bucket(route) do
-    route_time = :ets.lookup(:ratelimit_buckets, route)
-    global_time = :ets.lookup(:ratelimit_buckets, "GLOBAL")
-
-    Enum.max_by([route_time, global_time], fn info ->
-      case info do
-        [] -> -1
-        [{_route, _remaining, reset_time, _latency}] -> reset_time
-      end
-    end)
-  end
-
-  def update_remaining(route, remaining) do
-    :ets.update_element(:ratelimit_buckets, route, {2, remaining})
-  end
-
-  def delete_bucket(route) do
-    :ets.delete(:ratelimit_buckets, route)
-  end
-
-  def get_ratelimit_timeout(route) do
-    case lookup_bucket(route) do
-      [] ->
-        :now
-
-      [{route, remaining, _reset_time, _latency}] when remaining > 0 ->
-        update_remaining(route, remaining - 1)
-        :now
-
-      [{_route, _remaining, reset_time, latency}] ->
-        reset_time - Util.now() + latency
-    end
-  end
-
-  @doc false
-  def remove_old_buckets do
-    # remove all buckets that have a reset time more than 1 hour ago
-    one_hour_ago = Util.now() - System.convert_time_unit(3600, :second, :millisecond)
-
-    # created from :ets.fun2ms(
-    # fn {_, _, reset_time, _} when reset_time < one_hour_ago -> true end
-    # )
-    match_spec = [{{:_, :_, :"$1", :_}, [{:<, :"$1", one_hour_ago}], [true]}]
-    :ets.select_delete(:ratelimit_buckets, match_spec)
-  end
-end
diff --git a/lib/nostrum/api/ratelimiter.ex b/lib/nostrum/api/ratelimiter.ex
index a652ff27..3179bee1 100644
--- a/lib/nostrum/api/ratelimiter.ex
+++ b/lib/nostrum/api/ratelimiter.ex
@@ -6,9 +6,10 @@ defmodule Nostrum.Api.Ratelimiter do
 
   use GenServer
 
-  alias Nostrum.Api.{Base, Bucket}
+  alias Nostrum.Api.Base
   alias Nostrum.Constants
   alias Nostrum.Error.ApiError
+  alias Nostrum.Store.RatelimitBucket
   alias Nostrum.Util
 
   require Logger
@@ -34,7 +35,6 @@ defmodule Nostrum.Api.Ratelimiter do
   end
 
   def init([]) do
-    :ets.new(:ratelimit_buckets, [:set, :public, :named_table])
     domain = to_charlist(Constants.domain())
 
     open_opts = %{retry: 1_000_000_000, tls_opts: Constants.gun_tls_opts()}
@@ -48,18 +48,10 @@ defmodule Nostrum.Api.Ratelimiter do
     {:ok, conn_pid}
   end
 
-  @doc """
-  Empties all buckets, voiding any saved ratelimit values.
-  """
-  @spec empty_buckets() :: true
-  def empty_buckets do
-    :ets.delete_all_objects(:ratelimit_buckets)
-  end
-
   defp get_retry_time(route, method) do
     route
     |> get_endpoint(method)
-    |> Bucket.get_ratelimit_timeout()
+    |> RatelimitBucket.timeout_for()
   end
 
   def queue(request) do
@@ -84,9 +76,6 @@ defmodule Nostrum.Api.Ratelimiter do
       :now ->
         {:reply, do_request(request, conn), conn}
 
-      time when time < 0 ->
-        {:reply, do_request(request, conn), conn}
-
       time ->
         {:reply, {:error, {:retry_after, time}}, conn}
     end
@@ -114,7 +103,7 @@ defmodule Nostrum.Api.Ratelimiter do
   end
 
   def handle_info(:remove_old_buckets, state) do
-    Bucket.remove_old_buckets()
+    RatelimitBucket.cleanup(:timer.hours(1))
     Process.send_after(self(), :remove_old_buckets, :timer.hours(1))
     {:noreply, state}
   end
@@ -148,7 +137,7 @@ defmodule Nostrum.Api.Ratelimiter do
     remaining = unless is_nil(remaining), do: String.to_integer(remaining)
 
     reset = header_value(headers, "x-ratelimit-reset")
-    reset = unless is_nil(reset), do: String.to_float(reset)
+    reset = unless is_nil(reset), do: :erlang.trunc(:math.ceil(String.to_float(reset)))
     retry_after = header_value(headers, "retry-after")
 
     retry_after =
@@ -156,7 +145,7 @@ defmodule Nostrum.Api.Ratelimiter do
         # Since for some reason this might not contain a "."
         # and String.to_float raises if it doesn't
         {retry_after, ""} = Float.parse(retry_after)
-        retry_after
+        :erlang.trunc(:math.ceil(retry_after))
       end
 
     origin_timestamp =
@@ -179,11 +168,11 @@ defmodule Nostrum.Api.Ratelimiter do
   end
 
   defp update_bucket(route, remaining, reset_time, latency) do
-    Bucket.update_bucket(route, remaining, reset_time * 1000, latency)
+    RatelimitBucket.update(route, remaining, reset_time * 1000, latency)
   end
 
   defp update_global_bucket(_route, _remaining, retry_after, latency) do
-    Bucket.update_bucket("GLOBAL", 0, retry_after + Util.now(), latency)
+    RatelimitBucket.update("GLOBAL", 0, retry_after + Util.now(), latency)
   end
 
   defp date_string_to_unix(header) do
diff --git a/lib/nostrum/application.ex b/lib/nostrum/application.ex
index 41b6cc53..3266559c 100644
--- a/lib/nostrum/application.ex
+++ b/lib/nostrum/application.ex
@@ -14,8 +14,9 @@ defmodule Nostrum.Application do
     setup_ets_tables()
 
     children = [
-      Nostrum.Api.Ratelimiter,
+      Nostrum.Store.Supervisor,
       Nostrum.ConsumerGroup,
+      Nostrum.Api.Ratelimiter,
       Nostrum.Shard.Connector,
       Nostrum.Cache.CacheSupervisor,
       Nostrum.Shard.Supervisor,
@@ -30,9 +31,6 @@ defmodule Nostrum.Application do
   @doc false
   def setup_ets_tables do
     :ets.new(:gateway_url, [:set, :public, :named_table])
-    :ets.new(:unavailable_guilds, [:set, :public, :named_table])
-    :ets.new(:guild_shard_map, [:set, :public, :named_table])
-    :ets.new(:channel_guild_map, [:set, :public, :named_table])
   end
 
   defp check_executables do
diff --git a/lib/nostrum/cache/cache_supervisor.ex b/lib/nostrum/cache/cache_supervisor.ex
index 39a1e2ad..5dc24a4b 100644
--- a/lib/nostrum/cache/cache_supervisor.ex
+++ b/lib/nostrum/cache/cache_supervisor.ex
@@ -21,6 +21,7 @@ defmodule Nostrum.Cache.CacheSupervisor do
     children = [
       Nostrum.Cache.Me,
       # Uses the configured cache implementations.
+      Nostrum.Cache.ChannelGuildMapping,
       Nostrum.Cache.GuildCache,
       Nostrum.Cache.MemberCache,
       Nostrum.Cache.UserCache,
diff --git a/lib/nostrum/cache/channel_cache/ets.ex b/lib/nostrum/cache/channel_cache/ets.ex
index 53242e0e..ed34145c 100644
--- a/lib/nostrum/cache/channel_cache/ets.ex
+++ b/lib/nostrum/cache/channel_cache/ets.ex
@@ -101,7 +101,7 @@ defmodule Nostrum.Cache.ChannelCache.ETS do
           Map.get(channels, id, {:error, :id_not_found})
         end)
         |> case do
-          {:error, :id_not_found} ->
+          {:error, :id_not_found_on_guild_lookup} ->
             {:error, :channel_not_found}
 
           res ->
diff --git a/lib/nostrum/cache/channel_guild_mapping.ex b/lib/nostrum/cache/channel_guild_mapping.ex
new file mode 100644
index 00000000..4f48da27
--- /dev/null
+++ b/lib/nostrum/cache/channel_guild_mapping.ex
@@ -0,0 +1,64 @@
+defmodule Nostrum.Cache.ChannelGuildMapping do
+  @default_implementation __MODULE__.ETS
+  @moduledoc """
+  Cache behaviour & dispatcher for mapping channel IDs to their respective guilds.
+
+  ## Purpose
+
+  While retrieving the channels on a specific guild is trivial as they are
+  stored on the guild, retrieving the guild a given channel belongs to is not
+  possible with the regular data mode of channels. This module allows to
+  retrieve guilds associated with a channel via `get/1`.
+
+  ## Configuration
+
+  By default, nostrum will use `#{@default_implementation}` to store the
+  mapping. To override this, set the `[:caches, :channel_guild_mapping]`
+  setting on nostrum's application configuration:
+
+  ```elixir
+  config :nostrum,
+    caches: %{
+      channel_guild_mapping: MyBot.Nostrum.Cache.ChannelGuildMapping
+    }
+  ```
+
+  This setting must be set at compile time.
+  """
+  @moduledoc since: "0.8.0"
+
+  @configured_cache :nostrum
+                    |> Application.compile_env(
+                      [:caches, :channel_guild_mapping],
+                      @default_implementation
+                    )
+
+  alias Nostrum.Struct.Channel
+  alias Nostrum.Struct.Guild
+
+  @doc """
+  Create a mapping of the given channel to the given guild.
+  """
+  @callback create(Channel.id(), Guild.id()) :: true
+
+  @doc """
+  Retrieve the guild ID for the given channel ID, if present.
+  """
+  @callback get(Channel.id()) :: Guild.id() | nil
+
+  @doc """
+  Remove any mapping associated with the given channel.
+  """
+  @callback delete(Channel.id()) :: true
+
+  @doc """
+  Retrieve the child specification for starting this mapping under a supervisor.
+  """
+  @callback child_spec(term()) :: Supervisor.child_spec()
+
+  defdelegate create(guild_id, shard_num), to: @configured_cache
+  defdelegate delete(guild_id), to: @configured_cache
+  defdelegate get(guild_id), to: @configured_cache
+  @doc false
+  defdelegate child_spec(opts), to: @configured_cache
+end
diff --git a/lib/nostrum/cache/channel_guild_mapping/ets.ex b/lib/nostrum/cache/channel_guild_mapping/ets.ex
new file mode 100644
index 00000000..1b08bbd5
--- /dev/null
+++ b/lib/nostrum/cache/channel_guild_mapping/ets.ex
@@ -0,0 +1,58 @@
+defmodule Nostrum.Cache.ChannelGuildMapping.ETS do
+  @table_name :nostrum_channel_guild_mapping
+  @moduledoc """
+  Maps channels to guilds via `:ets`.
+
+  Please use the function `table/0` for retrieving a reference to the table
+  used by nostrum.
+  """
+  @moduledoc since: "0.8.0"
+
+  alias Nostrum.Cache.ChannelGuildMapping
+  alias Nostrum.Struct.Channel
+  alias Nostrum.Struct.Guild
+
+  @behaviour ChannelGuildMapping
+
+  use Supervisor
+
+  @doc "Start the supervisor."
+  def start_link(init_arg) do
+    Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
+  end
+
+  @doc "Set up the ETS table."
+  @impl Supervisor
+  def init(_init_arg) do
+    :ets.new(@table_name, [:set, :public, :named_table])
+    Supervisor.init([], strategy: :one_for_one)
+  end
+
+  @doc "Retrieve the table used by this module."
+  @spec table() :: :ets.table()
+  def table, do: @table_name
+
+  @impl ChannelGuildMapping
+  @doc "Create a mapping of the given channel to the given guild."
+  @spec create(Channel.id(), Guild.id()) :: true
+  def create(channel_id, guild_id) do
+    :ets.insert(@table_name, {channel_id, guild_id})
+  end
+
+  @impl ChannelGuildMapping
+  @doc "Retrieve the guild ID for the given channel ID, if present."
+  @spec get(Channel.id()) :: Guild.id() | nil
+  def get(channel_id) do
+    case :ets.lookup(@table_name, channel_id) do
+      [{_channel, guild_id}] -> guild_id
+      [] -> nil
+    end
+  end
+
+  @impl ChannelGuildMapping
+  @doc "Remove any mapping associated with the given channel."
+  @spec delete(Channel.id()) :: true
+  def delete(channel_id) do
+    :ets.delete(@table_name, channel_id)
+  end
+end
diff --git a/lib/nostrum/cache/guild_cache.ex b/lib/nostrum/cache/guild_cache.ex
index 9d37fef2..2de4e0df 100644
--- a/lib/nostrum/cache/guild_cache.ex
+++ b/lib/nostrum/cache/guild_cache.ex
@@ -25,7 +25,7 @@ defmodule Nostrum.Cache.GuildCache do
   instead.
 
   You need to implement both of them for nostrum to work with your custom
-  cache. **You also need to implement `Supervisor` callbacks**, which will
+  cache. You also need to implement `c:child_spec/1` to allow nostrum to
   start your cache as a child under `Nostrum.Cache.CacheSupervisor`: As an
   example, the `Nostrum.Cache.GuildCache.ETS` implementation uses this to to
   set up its ETS table it uses for caching. See the callbacks section for every
@@ -74,10 +74,6 @@ defmodule Nostrum.Cache.GuildCache do
   ## Supervisor callbacks
   # These set up the backing cache.
   @doc false
-  defdelegate init(init_arg), to: @configured_cache
-  @doc false
-  defdelegate start_link(init_arg), to: @configured_cache
-  @doc false
   defdelegate child_spec(opts), to: @configured_cache
 
   ## Behaviour specification
diff --git a/lib/nostrum/cache/guild_cache/ets.ex b/lib/nostrum/cache/guild_cache/ets.ex
index 10d13d45..a8499144 100644
--- a/lib/nostrum/cache/guild_cache/ets.ex
+++ b/lib/nostrum/cache/guild_cache/ets.ex
@@ -19,8 +19,8 @@ defmodule Nostrum.Cache.GuildCache.ETS do
 
   @behaviour Nostrum.Cache.GuildCache
 
+  alias Nostrum.Cache.ChannelGuildMapping
   alias Nostrum.Cache.GuildCache
-  alias Nostrum.Cache.Mapping.ChannelGuild
   alias Nostrum.Snowflake
   alias Nostrum.Struct.Channel
   alias Nostrum.Struct.Emoji
@@ -110,9 +110,9 @@ defmodule Nostrum.Cache.GuildCache.ETS do
 
   def select_by(%{channel_id: channel_id}, selector)
       when is_snowflake(channel_id) and is_selector(selector) do
-    case ChannelGuild.get_guild(channel_id) do
-      {:ok, guild_id} -> select_by(%{id: guild_id}, selector)
-      {:error, _} = error -> error
+    case ChannelGuildMapping.get(channel_id) do
+      nil -> {:error, :channel_not_found}
+      guild_id -> select_by(%{id: guild_id}, selector)
     end
   end
 
diff --git a/lib/nostrum/cache/mapping/channel_guild.ex b/lib/nostrum/cache/mapping/channel_guild.ex
deleted file mode 100644
index c9338d59..00000000
--- a/lib/nostrum/cache/mapping/channel_guild.ex
+++ /dev/null
@@ -1,22 +0,0 @@
-defmodule Nostrum.Cache.Mapping.ChannelGuild do
-  @moduledoc """
-  Mapping of channel -> guild relationships.
-
-  Given just the `id` of a guild channel, there's no way to know what guild it
-  belongs to. This mapping is an ETS table that stores this information.
-  """
-
-  alias Nostrum.Struct.Channel
-  alias Nostrum.Struct.Guild
-
-  @doc """
-  Gets a `guild_id` from a `channel_id`.
-  """
-  @spec get_guild(Channel.id()) :: {:ok, Guild.id()} | {:error, :id_not_found}
-  def get_guild(channel_id) do
-    case :ets.lookup(:channel_guild_map, channel_id) do
-      [{_channel_id, guild_id}] -> {:ok, guild_id}
-      [] -> {:error, :id_not_found}
-    end
-  end
-end
diff --git a/lib/nostrum/cache/mapping/guild_shard.ex b/lib/nostrum/cache/mapping/guild_shard.ex
deleted file mode 100644
index 8f97e6ef..00000000
--- a/lib/nostrum/cache/mapping/guild_shard.ex
+++ /dev/null
@@ -1,21 +0,0 @@
-defmodule Nostrum.Cache.Mapping.GuildShard do
-  @moduledoc """
-  Mapping of guild -> shard relationships.
-
-  Guild servers do not inherently know which shard they came from. This mapping
-  is an ETS table that stores this information.
-  """
-
-  alias Nostrum.Struct.Guild
-
-  @doc """
-  Gets the a shard num from a `guild_id`.
-  """
-  @spec get_shard(Guild.id()) :: {:ok, integer} | {:error, :id_not_found}
-  def get_shard(guild_id) do
-    case :ets.lookup(:guild_shard_map, guild_id) do
-      [{_guild_id, shard_num}] -> {:ok, shard_num}
-      [] -> {:error, :id_not_found}
-    end
-  end
-end
diff --git a/lib/nostrum/cache/member_cache.ex b/lib/nostrum/cache/member_cache.ex
index 73a264ce..4ca9ca73 100644
--- a/lib/nostrum/cache/member_cache.ex
+++ b/lib/nostrum/cache/member_cache.ex
@@ -194,9 +194,5 @@ defmodule Nostrum.Cache.MemberCache do
   ## Supervisor callbacks
   # These set up the backing cache.
   @doc false
-  defdelegate init(init_arg), to: @configured_cache
-  @doc false
-  defdelegate start_link(init_arg), to: @configured_cache
-  @doc false
   defdelegate child_spec(opts), to: @configured_cache
 end
diff --git a/lib/nostrum/cache/user_cache.ex b/lib/nostrum/cache/user_cache.ex
index 5533f065..2bc62503 100644
--- a/lib/nostrum/cache/user_cache.ex
+++ b/lib/nostrum/cache/user_cache.ex
@@ -23,12 +23,6 @@ defmodule Nostrum.Cache.UserCache do
 
   ## Supervisor callbacks
 
-  @doc false
-  defdelegate init(init_arg), to: @configured_cache
-
-  @doc false
-  defdelegate start_link(init_arg), to: @configured_cache
-
   @doc false
   defdelegate child_spec(opts), to: @configured_cache
 
diff --git a/lib/nostrum/shard/dispatch.ex b/lib/nostrum/shard/dispatch.ex
index 1ac22c40..a54d9bf3 100644
--- a/lib/nostrum/shard/dispatch.ex
+++ b/lib/nostrum/shard/dispatch.ex
@@ -1,9 +1,19 @@
 defmodule Nostrum.Shard.Dispatch do
   @moduledoc false
 
-  alias Nostrum.Cache.{ChannelCache, GuildCache, MemberCache, PresenceCache, UserCache}
+  alias Nostrum.Cache.{
+    ChannelCache,
+    ChannelGuildMapping,
+    GuildCache,
+    MemberCache,
+    PresenceCache,
+    UserCache
+  }
+
   alias Nostrum.Cache.Me
   alias Nostrum.Shard.{Intents, Session}
+  alias Nostrum.Store.GuildShardMapping
+  alias Nostrum.Store.UnavailableGuild, as: UnavailableGuildStore
 
   alias Nostrum.Struct.Event.{
     AutoModerationRuleExecute,
@@ -60,9 +70,10 @@ defmodule Nostrum.Shard.Dispatch do
   defp format_event(:noop), do: :noop
 
   defp check_new_or_unavailable(guild_id) do
-    case :ets.lookup(:unavailable_guilds, guild_id) do
-      [] -> :GUILD_CREATE
-      [_] -> :GUILD_AVAILABLE
+    if UnavailableGuildStore.is?(guild_id) do
+      :GUILD_AVAILABLE
+    else
+      :GUILD_CREATE
     end
   end
 
@@ -83,7 +94,7 @@ defmodule Nostrum.Shard.Dispatch do
   end
 
   def handle_event(:CHANNEL_CREATE = event, %{type: t} = p, state) when t in [0, 2] do
-    :ets.insert(:channel_guild_map, {p.id, p.guild_id})
+    ChannelGuildMapping.create(p.id, p.guild_id)
     {event, GuildCache.channel_create(p.guild_id, p), state}
   end
 
@@ -97,7 +108,7 @@ defmodule Nostrum.Shard.Dispatch do
   end
 
   def handle_event(:CHANNEL_DELETE = event, %{type: t} = p, state) when t in [0, 2] do
-    :ets.delete(:channel_guild_map, p.id)
+    ChannelGuildMapping.delete(p.id)
     {event, GuildCache.channel_delete(p.guild_id, p.id), state}
   end
 
@@ -128,7 +139,7 @@ defmodule Nostrum.Shard.Dispatch do
   end
 
   def handle_event(:GUILD_CREATE, %{unavailable: true} = guild, state) do
-    :ets.insert(:unavailable_guilds, {guild.id, guild})
+    UnavailableGuildStore.create(guild.id)
     {:GUILD_UNAVAILABLE, UnavailableGuild.to_struct(guild), state}
   end
 
@@ -143,10 +154,10 @@ defmodule Nostrum.Shard.Dispatch do
     Enum.each(guild.members, fn member -> UserCache.create(member.user) end)
     MemberCache.bulk_create(guild.id, guild.members)
 
-    :ets.insert(:guild_shard_map, {guild.id, state.shard_num})
+    GuildShardMapping.create(guild.id, state.shard_num)
 
     Enum.each(guild.channels, fn channel ->
-      :ets.insert(:channel_guild_map, {channel.id, guild.id})
+      ChannelGuildMapping.create(channel.id, guild.id)
     end)
 
     has_members = Intents.has_intent?(:guild_members)
@@ -173,7 +184,7 @@ defmodule Nostrum.Shard.Dispatch do
   def handle_event(:GUILD_UPDATE = event, p, state), do: {event, GuildCache.update(p), state}
 
   def handle_event(:GUILD_DELETE = event, p, state) do
-    :ets.delete(:guild_shard_map, p.id)
+    GuildShardMapping.delete(p.id)
     {event, {GuildCache.delete(p.id), Map.get(p, :unavailable, false)}, state}
   end
 
diff --git a/lib/nostrum/shard/supervisor.ex b/lib/nostrum/shard/supervisor.ex
index b481c98f..bc3a55bf 100644
--- a/lib/nostrum/shard/supervisor.ex
+++ b/lib/nostrum/shard/supervisor.ex
@@ -3,10 +3,10 @@ defmodule Nostrum.Shard.Supervisor do
 
   use Supervisor
 
-  alias Nostrum.Cache.Mapping.GuildShard
   alias Nostrum.Error.CacheError
   alias Nostrum.Shard
   alias Nostrum.Shard.Session
+  alias Nostrum.Store.GuildShardMapping
   alias Nostrum.Util
 
   require Logger
@@ -54,7 +54,7 @@ defmodule Nostrum.Shard.Supervisor do
   end
 
   def update_voice_state(guild_id, channel_id, self_mute, self_deaf) do
-    case GuildShard.get_shard(guild_id) do
+    case GuildShardMapping.get(guild_id) do
       {:ok, shard_num} ->
         :"Shard-#{shard_num}"
         |> Supervisor.which_children()
diff --git a/lib/nostrum/store/guild_shard_mapping.ex b/lib/nostrum/store/guild_shard_mapping.ex
new file mode 100644
index 00000000..73a2ebd9
--- /dev/null
+++ b/lib/nostrum/store/guild_shard_mapping.ex
@@ -0,0 +1,88 @@
+defmodule Nostrum.Store.GuildShardMapping do
+  @default_implementation __MODULE__.ETS
+  @moduledoc """
+  Behaviour & dispatcher for storing guild to shard mappings.
+
+  ## Purpose
+
+  When nostrum needs to run API calls over the gateway and multiple shards are
+  used, it needs to determine which shard process will run the request. This
+  module stores a mapping of guild IDs to their respective shard numbers. It is
+  unlikely you need to use this module directly yourself.
+
+  ## Configuration
+
+  By default, nostrum will use `#{@default_implementation}` to store the
+  mapping. To override this, set the `[:stores, :guild_shard_mapping]` setting
+  on nostrum's application configuration:
+
+  ```elixir
+  config :nostrum,
+    stores: %{
+      guild_shard_mapping: MyBot.Nostrum.Store.GuildShardMapping
+    }
+  ```
+
+  This setting must be set at compile time.
+
+  ## See also
+
+  The module `Nostrum.Registry.Shard` associates shard numbers with their
+  respective shard session processes.
+  """
+
+  # XXX: This module should really be storing the Shard.Session PID instead, as
+  # it currently relies on name registration of the shard, see
+  # `Nostrum.Shard.Supervisor.update_voice_state`. For that to work, the
+  # `WSState` struct needs to be expanded to store the shard PID in addition to
+  # the shard number. It looks like this is already done using the `conn_pid`
+  # field. However, this introduces an additional problem: when the shard
+  # session goes down, we need to update this module with the new shard PID
+  # associated with the guild.
+  #
+  # The following solution is proposed: - Keep the mapping of guild_id ->
+  # shard_num - Introduce a mapping of shard_num -> shard_pid => :global Shard
+  # sessions register themselves via `:global`
+  #
+  # So we need to add another new mapping. Or actually: we don't, because we
+  # will basically reinvent `:global` at that point.
+  #
+  @configured_store :nostrum
+                    |> Application.compile_env(
+                      [:stores, :guild_shard_mapping],
+                      @default_implementation
+                    )
+
+  @moduledoc since: "0.8.0"
+
+  alias Nostrum.Struct.Guild
+  alias Nostrum.Struct.WSState
+
+  @doc """
+  Create a new mapping for the given guild ID to the given shard ID.
+  """
+  @callback create(Guild.id(), WSState.shard_num()) :: true
+
+  @doc """
+  Delete any stored mappings for the given guild ID.
+  """
+  @callback delete(Guild.id()) :: true
+
+  @doc """
+  Retrieve the shard number for the given guild ID.
+
+  Returns `nil` if no associated shard number was found.
+  """
+  @callback get(Guild.id()) :: WSState.shard_num() | nil
+
+  @doc """
+  Retrieve the child specification for starting this mapping under a supervisor.
+  """
+  @callback child_spec(term()) :: Supervisor.child_spec()
+
+  defdelegate create(guild_id, shard_num), to: @configured_store
+  defdelegate delete(guild_id), to: @configured_store
+  defdelegate get(guild_id), to: @configured_store
+  @doc false
+  defdelegate child_spec(opts), to: @configured_store
+end
diff --git a/lib/nostrum/store/guild_shard_mapping/ets.ex b/lib/nostrum/store/guild_shard_mapping/ets.ex
new file mode 100644
index 00000000..f09c0493
--- /dev/null
+++ b/lib/nostrum/store/guild_shard_mapping/ets.ex
@@ -0,0 +1,55 @@
+defmodule Nostrum.Store.GuildShardMapping.ETS do
+  @moduledoc """
+  Stores a mapping of guild IDs to their shard numbers using `:ets`.
+
+  If programmatic access to the ETS table is needed, please use the `table/0`
+  function.
+
+  Please do not use this module directly, apart from special functions such as
+  `table/0`. Use `Nostrum.Store.GuildShardMapping` to call the configured
+  mapping instead.
+  """
+  @moduledoc since: "0.8.0"
+
+  alias Nostrum.Store.GuildShardMapping
+
+  @behaviour GuildShardMapping
+
+  @table_name :nostrum_guild_shard_map
+
+  use Supervisor
+
+  @doc "Retrieve the ETS table reference used for the store."
+  @spec table :: :ets.table()
+  def table, do: @table_name
+
+  @doc "Start the supervisor."
+  def start_link(init_arg) do
+    Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
+  end
+
+  @doc "Set up the store's ETS table."
+  @impl Supervisor
+  def init(_init_arg) do
+    :ets.new(@table_name, [:set, :public, :named_table])
+    Supervisor.init([], strategy: :one_for_one)
+  end
+
+  @impl GuildShardMapping
+  def create(guild_id, shard_num) do
+    :ets.insert(@table_name, {guild_id, shard_num})
+  end
+
+  @impl GuildShardMapping
+  def delete(guild_id) do
+    :ets.delete(@table_name, guild_id)
+  end
+
+  @impl GuildShardMapping
+  def get(guild_id) do
+    case :ets.lookup(@table_name, guild_id) do
+      [{_guild_id, shard_num}] -> shard_num
+      [] -> nil
+    end
+  end
+end
diff --git a/lib/nostrum/store/ratelimit_bucket.ex b/lib/nostrum/store/ratelimit_bucket.ex
new file mode 100644
index 00000000..3caa47b8
--- /dev/null
+++ b/lib/nostrum/store/ratelimit_bucket.ex
@@ -0,0 +1,152 @@
+defmodule Nostrum.Store.RatelimitBucket do
+  @default_implementation __MODULE__.ETS
+  @moduledoc """
+  Behaviour & dispatcher for storing ratelimit buckets.
+
+  ## Purpose
+
+  Calls to Discord's API are ratelimited, and we are informed about our
+  remaining calls by Discord after issuing them. This module concerns itself
+  with storing this information to prevent running into HTTP 429 errors. As
+  this is used mainly by `Nostrum.Api.Ratelimiter`, it is unlikely you need to
+  use it directly yourself.
+
+  ## Configuration
+
+  By default, nostrum will use `#{@default_implementation}` to store ratelimit
+  buckets. To override this, set the `[:stores, :ratelimit_buckets]` setting on
+  nostrum's application configuration:
+
+  ```elixir
+  config :nostrum,
+    stores: %{
+      ratelimit_buckets: MyBot.Nostrum.Store.RatelimitBucket
+    }
+  ```
+
+  This setting must be set at compile time.
+
+  ## Implementation
+
+  If implementing your own ratelimit bucket store, in addition to the callbacks
+  of this module, you must also provide the function `child_spec/1`. The
+  recommended approach is to spawn a `Supervisor` to manage your store.
+  """
+  @moduledoc since: "0.8.0"
+
+  @configured_store :nostrum
+                    |> Application.compile_env(
+                      [:stores, :ratelimit_buckets],
+                      @default_implementation
+                    )
+
+  alias Nostrum.Util
+
+  @typedoc """
+  The route a bucket applies to.
+
+  The constant value `"GLOBAL"` is used for the global bucket.
+  """
+  @type route :: String.t()
+
+  @typedoc "Remaining calls for a bucket."
+  @type remaining :: non_neg_integer()
+
+  @typedoc "Time at which a bucket resets, in milliseconds."
+  @type reset_time :: pos_integer()
+
+  @typedoc "Latency between us and our latest call for this bucket, in milliseconds."
+  @type latency :: pos_integer()
+
+  @typedoc "Individual bucket information for a route."
+  @type bucket :: {route(), remaining(), reset_time(), latency()}
+
+  @doc """
+  Update the given bucket to have the given remaining calls.
+
+  Return whether the update has affected anything.
+
+  Normally used after issuing an API call for a previously used route.
+  """
+  @callback update(route(), remaining()) :: boolean()
+
+  @doc """
+  Update the given bucket with full rate limit information.
+
+  Whilst `c:update/2` is based around the assumption that you only know the
+  remaining calls (for instance, after issuing one), this function is used
+  after receiving full rate limiting information after an API call.
+  """
+  @callback update(route(), remaining(), reset_time(), latency()) :: true
+
+  @doc """
+  Retrieve bucket information by route.
+
+  If no information is available, return `nil`.
+  """
+  @callback lookup(route()) :: bucket() | nil
+
+  @doc """
+  Clean up entries in the bucket older than the given amount of milliseconds.
+
+  This function is called automatically by the ratelimiter in regular
+  intervals.
+
+  Return the amount of deleted entries.
+  """
+  @callback cleanup(pos_integer()) :: non_neg_integer()
+
+  @doc """
+  Retrieve the child specification for starting this mapping under a supervisor.
+  """
+  @callback child_spec(term()) :: Supervisor.child_spec()
+
+  @doc """
+  Receive the time to wait before issuing more calls to the given route.
+
+  This function must only be called prior to issuing the actual API call: it
+  will decrement the remaining calls counter from the given bucket.
+
+  Nostrum takes the API latency into account when issuing these calls, and uses
+  the current time to determine when it expires. It is therefore assumed that
+  each node has a relatively equal latency to the API, and the nodes have
+  little to no time drift.
+  """
+  @spec timeout_for(route()) :: remaining() | :now
+  def timeout_for(route) do
+    case lookup(route) do
+      # XXX: In multi-node - or rather, with multiple rate limiter processes,
+      # this poses a race condition. It needs to be atomic.
+      {route, remaining, _reset_time, _latency} when remaining > 0 ->
+        update(route, remaining - 1)
+        :now
+
+      {_route, _remaining, reset_time, latency} ->
+        # Oh fuck. What the fuck did I just get myself into. I just realized:
+        # if we introduce some shared storage for this, whatever it is, then we
+        # probably also need to introduce some form of fucking time
+        # synchronization or some other absolute hellspawn of evilkind into
+        # this pure project. How would the latency be of value for the other
+        # servers? Well, if it's the same data center or even the same city our
+        # state, it's probably fine. But a unix timestamp? In milliseconds?
+        # Distributed across servers? What the fuck, man. Do people configure
+        # ntpd properly? Do people even know what ntpd is anymore? Do I even
+        # know what I'm doing anymore?
+
+        case reset_time - Util.now() + latency do
+          time when time < 0 -> :now
+          time -> time
+        end
+
+      nil ->
+        :now
+    end
+  end
+
+  defdelegate update(route, remaining), to: @configured_store
+  defdelegate update(route, remaining, reset_time, latency), to: @configured_store
+  defdelegate lookup(route), to: @configured_store
+  defdelegate cleanup(age), to: @configured_store
+  @doc false
+  defdelegate child_spec(opts), to: @configured_store
+end
diff --git a/lib/nostrum/store/ratelimit_bucket/ets.ex b/lib/nostrum/store/ratelimit_bucket/ets.ex
new file mode 100644
index 00000000..e2414bc0
--- /dev/null
+++ b/lib/nostrum/store/ratelimit_bucket/ets.ex
@@ -0,0 +1,90 @@
+defmodule Nostrum.Store.RatelimitBucket.ETS do
+  @moduledoc """
+  Stores ratelimit buckets via `:ets`.
+
+  If programmatic access to the ETS table is needed, please use the `table/0`
+  function.
+
+  Please do not use this module directly, apart from special functions such as
+  `table/0`. Use `Nostrum.Store.RatelimitBucket` to call the configured
+  mapping instead.
+  """
+  @moduledoc since: "0.8.0"
+
+  alias Nostrum.Store.RatelimitBucket
+  alias Nostrum.Util
+  use Supervisor
+
+  @behaviour RatelimitBucket
+
+  @table_name :nostrum_ratelimit_buckets
+
+  @doc "Retrieve the ETS table reference used for the store."
+  @spec table :: :ets.table()
+  def table, do: @table_name
+
+  @doc "Start the supervisor."
+  def start_link(init_arg) do
+    Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
+  end
+
+  @doc "Set up the store's ETS table."
+  @impl Supervisor
+  def init(_init_arg) do
+    :ets.new(@table_name, [:set, :public, :named_table])
+    Supervisor.init([], strategy: :one_for_one)
+  end
+
+  @impl RatelimitBucket
+  @doc "Update an existing routes remaining calls."
+  @spec update(RatelimitBucket.route(), RatelimitBucket.remaining()) :: boolean()
+  def update(route, remaining) do
+    :ets.update_element(@table_name, route, {2, remaining})
+  end
+
+  @impl RatelimitBucket
+  @doc "Set up a new ratelimiter bucket from the given arguments."
+  @spec update(
+          RatelimitBucket.route(),
+          RatelimitBucket.remaining(),
+          RatelimitBucket.reset_time(),
+          RatelimitBucket.latency()
+        ) :: true
+  def update(route, remaining, reset_time, latency) do
+    :ets.insert(@table_name, {route, remaining, reset_time, latency})
+  end
+
+  @impl RatelimitBucket
+  @doc "Look up the most relevant ratelimiter bucket for the given route."
+  @spec lookup(RatelimitBucket.route()) :: RatelimitBucket.bucket() | nil
+  def lookup(route) do
+    route_time = :ets.lookup(@table_name, route)
+    global_time = :ets.lookup(@table_name, "GLOBAL")
+
+    max =
+      Enum.max_by([route_time, global_time], fn info ->
+        case info do
+          [] -> -1
+          [{_route, _remaining, reset_time, _latency}] -> reset_time
+        end
+      end)
+
+    case max do
+      [] -> nil
+      [entry] -> entry
+    end
+  end
+
+  @impl RatelimitBucket
+  @doc "Clean up bucket entries older than `age` milliseconds."
+  @spec cleanup(pos_integer()) :: non_neg_integer()
+  def cleanup(age) do
+    then = Util.now() - age
+
+    # created from :ets.fun2ms(
+    # fn {_, _, reset_time, _} when reset_time < then -> true end
+    # )
+    match_spec = [{{:_, :_, :"$1", :_}, [{:<, :"$1", then}], [true]}]
+    :ets.select_delete(@table_name, match_spec)
+  end
+end
diff --git a/lib/nostrum/store/supervisor.ex b/lib/nostrum/store/supervisor.ex
new file mode 100644
index 00000000..a3890f39
--- /dev/null
+++ b/lib/nostrum/store/supervisor.ex
@@ -0,0 +1,27 @@
+defmodule Nostrum.Store.Supervisor do
+  @moduledoc """
+  Supervises processes managing nostrum's internal state.
+
+  Please see the following modules for more details:
+  - `Nostrum.Store.GuildShardMapping`
+  - `Nostrum.Store.RatelimitBucket`
+  - `Nostrum.Store.UnavailableGuild`
+  """
+  @moduledoc since: "0.8.0"
+
+  use Supervisor
+
+  def start_link([]) do
+    Supervisor.start_link(__MODULE__, [], name: __MODULE__)
+  end
+
+  def init([]) do
+    children = [
+      Nostrum.Store.RatelimitBucket,
+      Nostrum.Store.GuildShardMapping,
+      Nostrum.Store.UnavailableGuild
+    ]
+
+    Supervisor.init(children, strategy: :one_for_one)
+  end
+end
diff --git a/lib/nostrum/store/unavailable_guild.ex b/lib/nostrum/store/unavailable_guild.ex
new file mode 100644
index 00000000..6802ad9f
--- /dev/null
+++ b/lib/nostrum/store/unavailable_guild.ex
@@ -0,0 +1,68 @@
+defmodule Nostrum.Store.UnavailableGuild do
+  @default_implementation __MODULE__.ETS
+  @moduledoc """
+  Behaviour & dispatcher for storing unavailable guilds.
+
+  ## Purpose
+
+  The `GUILD_CREATE` gateway event on its own provides no means to determine
+  whether the guild we receive is a guild that the bot joined, or a guild that
+  has just become available over the gateway. To work around this, this store
+  keeps track of unavailable guilds we received to determine whether a guild
+  sent over this event is unavailable or new. It is therefore unlikely this
+  module needs to be used outside of nostrum.
+
+  ## Configuration
+
+  By default, nostrum will use `#{@default_implementation}` to store
+  unavailable guilds. To override this, set the `[:stores,
+  :unavailable_guilds]` setting on nostrum's application configuration:
+
+  ```elixir
+  config :nostrum,
+    stores: %{
+      unavailable_guilds: MyBot.Nostrum.Store.UnavailableGuild
+    }
+  ```
+
+  This setting must be set at compile time.
+
+  ## Implementation
+
+  If implementing your own unavailable guild store, in addition to the
+  callbacks of this module, you must also provide the function `child_spec/1`.
+  The recommended approach is to spawn a `Supervisor` to manage your store.
+  """
+  @moduledoc since: "0.8.0"
+
+  alias Nostrum.Struct.Guild
+
+  @configured_store :nostrum
+                    |> Application.compile_env(
+                      [:stores, :unavailable_guilds],
+                      @default_implementation
+                    )
+
+  @doc """
+  Mark the given guild as unavailable.
+  """
+  @callback create(Guild.id()) :: true
+
+  @doc """
+  Return whether the guild is unavailable.
+  """
+  @callback is?(Guild.id()) :: boolean()
+
+  @doc """
+  Retrieve the child specification for starting this mapping under a supervisor.
+  """
+  @callback child_spec(term()) :: Supervisor.child_spec()
+
+  defdelegate create(guild_id), to: @configured_store
+  defdelegate is?(guild_id), to: @configured_store
+
+  ## Supervisor callbacks
+  # These set up the backing cache.
+  @doc false
+  defdelegate child_spec(opts), to: @configured_store
+end
diff --git a/lib/nostrum/store/unavailable_guild/ets.ex b/lib/nostrum/store/unavailable_guild/ets.ex
new file mode 100644
index 00000000..51e42388
--- /dev/null
+++ b/lib/nostrum/store/unavailable_guild/ets.ex
@@ -0,0 +1,54 @@
+defmodule Nostrum.Store.UnavailableGuild.ETS do
+  @moduledoc """
+  Stores guilds that are currently unavailable using `:ets`.
+
+  If programmatic access to the ETS table is needed, please use the `table/0`
+  function.
+
+  Please do not use this module directly, apart from special functions such as
+  `tabname/0`. Use `Nostrum.Store.UnavailableGuild` to call the configured
+  mapping instead.
+  """
+  @moduledoc since: "0.8.0"
+
+  alias Nostrum.Store.UnavailableGuild
+  alias Nostrum.Struct.Guild
+  use Supervisor
+
+  @behaviour UnavailableGuild
+
+  @table_name :nostrum_unavailable_guilds
+
+  @doc "Retrieve the ETS table reference used for the store."
+  @spec table :: :ets.table()
+  def table, do: @table_name
+
+  @doc "Start the supervisor."
+  def start_link(init_arg) do
+    Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
+  end
+
+  @impl Supervisor
+  @doc "Set up the store's ETS table."
+  def init(_init_arg) do
+    :ets.new(@table_name, [:set, :public, :named_table])
+    Supervisor.init([], strategy: :one_for_one)
+  end
+
+  @impl UnavailableGuild
+  @doc "Create the given guild as an unavailable guild."
+  @spec create(Guild.id()) :: true
+  def create(guild_id) do
+    :ets.insert(@table_name, {guild_id})
+  end
+
+  @impl UnavailableGuild
+  @doc "Return whether the given guild is unavailable."
+  @spec is?(Guild.id()) :: boolean()
+  def is?(guild_id) do
+    case :ets.lookup(@table_name, guild_id) do
+      [{_guild_id}] -> true
+      [] -> false
+    end
+  end
+end


Test patch:
diff --git a/test/nostrum/cache/channel_cache_test.exs b/test/nostrum/cache/channel_cache_test.exs
index 4d442988..0e423df0 100644
--- a/test/nostrum/cache/channel_cache_test.exs
+++ b/test/nostrum/cache/channel_cache_test.exs
@@ -1,7 +1,6 @@
 defmodule Nostrum.Cache.ChannelCacheTest do
   alias Nostrum.Struct.Channel
   alias Nostrum.Struct.Message
-  alias Nostrum.Application
   use ExUnit.Case
 
   @cache_modules [
@@ -24,8 +23,8 @@ defmodule Nostrum.Cache.ChannelCacheTest do
 
       describe "with an empty cache" do
         setup do
-          Application.setup_ets_tables()
-          [pid: start_supervised!(@cache)]
+          mapping_pid = start_supervised!(Nostrum.Cache.ChannelGuildMapping)
+          [pid: start_supervised!(@cache), mapping_pid: mapping_pid]
         end
 
         test "get/1 returns channel not found" do
@@ -44,10 +43,10 @@ defmodule Nostrum.Cache.ChannelCacheTest do
 
       describe "with cached channel" do
         setup do
-          Application.setup_ets_tables()
+          mapping_pid = start_supervised!(Nostrum.Cache.ChannelGuildMapping)
           pid = start_supervised!(@cache)
           @cache.create(@test_channel)
-          [pid: pid]
+          [pid: pid, mapping_pid: mapping_pid]
         end
 
         test "get/1 on dispatch module with message returns channel" do
diff --git a/test/nostrum/cache/channel_guild_mapping_meta_test.exs b/test/nostrum/cache/channel_guild_mapping_meta_test.exs
new file mode 100644
index 00000000..b524030a
--- /dev/null
+++ b/test/nostrum/cache/channel_guild_mapping_meta_test.exs
@@ -0,0 +1,37 @@
+defmodule Nostrum.Cache.ChannelGuildMappingMetaTest do
+  alias Nostrum.Cache.ChannelGuildMapping
+  use ExUnit.Case
+
+  @cache_modules [
+    # Dispatcher
+    ChannelGuildMapping,
+    # Implementations
+    ChannelGuildMapping.ETS
+  ]
+
+  for cache <- @cache_modules do
+    defmodule :"#{cache}Test" do
+      use ExUnit.Case
+      @cache cache
+      doctest @cache
+
+      setup do
+        [pid: start_supervised!(@cache)]
+      end
+
+      test "storing functionality" do
+        channel_id = :erlang.unique_integer([:positive])
+        guild_id = :erlang.unique_integer([:positive])
+
+        refute ChannelGuildMapping.get(channel_id)
+        assert ChannelGuildMapping.delete(channel_id)
+
+        assert ChannelGuildMapping.create(channel_id, guild_id)
+        assert ^guild_id = ChannelGuildMapping.get(channel_id)
+
+        assert ChannelGuildMapping.delete(channel_id)
+        refute ChannelGuildMapping.get(channel_id)
+      end
+    end
+  end
+end
diff --git a/test/nostrum/store/guild_shard_mapping_meta_test.exs b/test/nostrum/store/guild_shard_mapping_meta_test.exs
new file mode 100644
index 00000000..e1004a5d
--- /dev/null
+++ b/test/nostrum/store/guild_shard_mapping_meta_test.exs
@@ -0,0 +1,37 @@
+defmodule Nostrum.Store.GuildShardMappingMetaTest do
+  alias Nostrum.Store.GuildShardMapping
+  use ExUnit.Case
+
+  @store_modules [
+    # Dispatcher
+    GuildShardMapping,
+    # Implementations
+    GuildShardMapping.ETS
+  ]
+
+  for store <- @store_modules do
+    defmodule :"#{store}Test" do
+      use ExUnit.Case
+      @store store
+      doctest @store
+
+      setup do
+        [pid: start_supervised!(@store)]
+      end
+
+      test "storing functionality" do
+        guild_id = :erlang.unique_integer([:positive])
+        shard_num = :erlang.unique_integer([:positive])
+
+        refute GuildShardMapping.get(guild_id)
+        assert GuildShardMapping.delete(guild_id)
+
+        assert GuildShardMapping.create(guild_id, shard_num)
+        assert ^shard_num = GuildShardMapping.get(guild_id)
+
+        assert GuildShardMapping.delete(guild_id)
+        refute GuildShardMapping.get(guild_id)
+      end
+    end
+  end
+end
diff --git a/test/nostrum/store/ratelimit_bucket_meta_test.exs b/test/nostrum/store/ratelimit_bucket_meta_test.exs
new file mode 100644
index 00000000..60a754d7
--- /dev/null
+++ b/test/nostrum/store/ratelimit_bucket_meta_test.exs
@@ -0,0 +1,58 @@
+defmodule Nostrum.Store.RatelimitBucketMetaTest do
+  alias Nostrum.Store.RatelimitBucket
+  use ExUnit.Case
+
+  @store_modules [
+    # Dispatcher
+    RatelimitBucket,
+    # Implementations
+    RatelimitBucket.ETS
+  ]
+
+  for store <- @store_modules do
+    defmodule :"#{store}Test" do
+      use ExUnit.Case
+      @store store
+      doctest @store
+
+      setup do
+        [pid: start_supervised!(@store)]
+      end
+
+      describe "empty lookup" do
+        test "returns nothing" do
+          refute @store.lookup("route")
+        end
+
+        test "succeeds in cleanup" do
+          assert 0 = @store.cleanup(0)
+        end
+
+        test "allows update for new bucket" do
+          reset_time = :erlang.unique_integer([:positive])
+          assert @store.update("ROUTE", 3, reset_time, 123)
+        end
+      end
+
+      describe "updating existing bucket" do
+        @route "TESTBUCKET"
+
+        setup do
+          reset_time = :erlang.unique_integer([:positive])
+          remaining_calls = 3
+          latency = 123
+          @store.update(@route, remaining_calls, reset_time, latency)
+          [remaining: remaining_calls, reset_time: reset_time, latency: latency]
+        end
+
+        test "returns bucket", %{remaining: remaining, reset_time: reset_time, latency: latency} do
+          assert {@route, ^remaining, ^reset_time, ^latency} = @store.lookup(@route)
+        end
+
+        test "timeout_for/1" do
+          assert RatelimitBucket.timeout_for(@route)
+        end
+      end
+    end
+  end
+end
diff --git a/test/nostrum/store/unavailable_guild_meta_test.exs b/test/nostrum/store/unavailable_guild_meta_test.exs
new file mode 100644
index 00000000..81d4b9d5
--- /dev/null
+++ b/test/nostrum/store/unavailable_guild_meta_test.exs
@@ -0,0 +1,30 @@
+defmodule Nostrum.Store.UnavailableGuildMetaTest do
+  alias Nostrum.Store.UnavailableGuild
+  use ExUnit.Case
+
+  @store_modules [
+    # Dispatcher
+    UnavailableGuild,
+    # Implementations
+    UnavailableGuild.ETS
+  ]
+
+  for store <- @store_modules do
+    defmodule :"#{store}Test" do
+      use ExUnit.Case
+      @store store
+      doctest @store
+
+      setup do
+        [pid: start_supervised!(@store)]
+      end
+
+      test "create/1 and is?/1" do
+        guild_id = :erlang.unique_integer([:positive])
+        refute UnavailableGuild.is?(guild_id)
+        UnavailableGuild.create(guild_id)
+        assert UnavailableGuild.is?(guild_id)
+      end
+    end
+  end
+end