Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Message cache #595

Merged
merged 12 commits into from
May 26, 2024
11 changes: 11 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,14 @@ config :nostrum,
config :logger, :console, metadata: [:shard, :guild, :channel]

if File.exists?("config/secret.exs"), do: import_config("secret.exs")

if Mix.env() == :test do
config :nostrum,
# constrain the size of the message cache in tests
# to make it easier to test eviction
caches: [
message_cache_size_limit: 10,
message_cache_eviction_count: 4,
message_cache_table_name: :nostrum_messages_test
]
end
174 changes: 174 additions & 0 deletions lib/nostrum/cache/message_cache.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
defmodule Nostrum.Cache.MessageCache do
@default_cache_implementation Nostrum.Cache.MessageCache.Noop

@moduledoc """
Cache behaviour & dispatcher for Discord messages.

By default, #{@default_cache_implementation} will be used for caching
messages. You can override this in the `:caches` option of the `nostrum`
application by setting the `:messages` field to a different module.

Unlike the other caches, the default is a no-op cache, as messages take
up a lot of memory and most bots do not need messages to be cached.
If you would like to cache messages, you can change the cache implementation
to one of the provided modules under `Nostrum.Cache.MessageCache`
or write your own.

## Writing your own message cache

As with the other caches, the message cache API consists of two parts:

- The functions that nostrum calls, such as `c:create/1` or `c:update/1`.
These **do not create any objects in the Discord API**, they are purely
created to update the cached data from data that Discord sends us. If you
want to create objects on Discord, use the functions exposed by `Nostrum.Api` instead.

- the `c:child_spec/1` callback for starting the cache under a supervisor.

You need to implement both of them for nostrum to work with your custom
cache.
"""

@configured_cache :nostrum
|> Application.compile_env(
[:caches, :messages],
@default_cache_implementation
)

alias Nostrum.Struct.{Channel, Message}

# callbacks

@doc """
Retrieve a single `Nostrum.Struct.Message` from the cache by channel id and message id.
Th3-M4jor marked this conversation as resolved.
Show resolved Hide resolved
"""
@callback get(Message.id()) :: {:ok, Message.t()} | {:error, :not_found}

@doc """
Creates a message in the cache.

The argument given is the raw message payload from Discord's gateway.
"""
@callback create(map()) :: Message.t()

@doc """
Updates a message in the cache.

The argument given is the raw message payload from Discord's gateway,
and the return value is a tuple of the updated message and the old message if
it was found in the cache, otherwise `nil`.
"""
@callback update(map()) :: {old_message :: Message.t() | nil, updated_message :: Message.t()}

@doc """
Deletes a message from the cache.

Expects the deleted message to be returned if it was found.
"""
@callback delete(Channel.id(), Message.id()) :: Message.t() | nil

@doc """
Deletes multiple messages from the cache, any message id's given
Th3-M4jor marked this conversation as resolved.
Show resolved Hide resolved
will always be for the same channel.

Returns a list of the deleted messages,
if a message was not found in the cache, it will
still be included in the returned list with
only the id and channel_id set.
"""
@callback bulk_delete(Channel.id(), [Message.id()]) :: [Message.t()]

@doc """
Callback for when a channel is deleted
any messages in the cache for that channel should be removed.
"""
Th3-M4jor marked this conversation as resolved.
Show resolved Hide resolved
@callback channel_delete(Channel.id()) :: :ok

@doc """
Return a QLC query handle for the cache for read operations.

This is used by nostrum to provide any read operations on the cache. Write
operations still need to be implemented separately.

The Erlang manual on [Implementing a QLC
Table](https://www.erlang.org/doc/man/qlc.html#implementing_a_qlc_table)
contains examples for implementation. To prevent full table scans, accept
match specifications in your `TraverseFun` and implement a `LookupFun` as
documented.

The query handle must return items in the form `{channel_id, author_id, message}`, where:
- `channel_id` is a `t:Nostrum.Struct.Channel.id/0`,
- `author_id` is a `t:Nostrum.Struct.User.id/0`, and
- `message` is a `t:Nostrum.Struct.Message.t/0`.
Th3-M4jor marked this conversation as resolved.
Show resolved Hide resolved

If your cache needs some form of setup or teardown for QLC queries (such as
opening connections), see `c:wrap_qlc/1`.
"""
@callback query_handle() :: :qlc.query_handle()

@doc """
Retrieve the child spec for starting the cache under a supervisor.

This callback is optional, and if not implemented, the cache will not be
started under a supervisor.
"""
@callback child_spec(term()) :: Supervisor.child_spec()

@doc """
A function that should wrap any `:qlc` operations.

If you implement a cache that is backed by a database and want to perform
cleanup and teardown actions such as opening and closing connections,
managing transactions and so on, you want to implement this function. nostrum
will then effectively call `wrap_qlc(fn -> :qlc.e(...) end)`.

If your cache does not need any wrapping, you can omit this.
"""
@callback wrap_qlc((-> result)) :: result when result: term()
@optional_callbacks wrap_qlc: 1

# User-facing

@doc """
Retrieve a message from the cache by channel and message id.
"""
defdelegate get(message_id), to: @configured_cache

@doc """
Call `c:wrap_qlc/1` on the given cache, if implemented.

If no cache is given, calls out to the default cache.
"""
@spec wrap_qlc((-> result)) :: result when result: term()
@spec wrap_qlc(module(), (-> result)) :: result when result: term()
def wrap_qlc(cache \\ @configured_cache, fun) do
if function_exported?(cache, :wrap_qlc, 1) do
cache.wrap_qlc(fun)
else
fun.()
end
end

# Nostrum-facing

@doc false
defdelegate create(message), to: @configured_cache
@doc false
defdelegate update(message), to: @configured_cache
@doc false
defdelegate delete(channel_id, message_id), to: @configured_cache
@doc false
defdelegate bulk_delete(channel_id, message_ids), to: @configured_cache
@doc false
defdelegate channel_delete(channel_id), to: @configured_cache

@doc """
Return the QLC handle of the configured cache.
"""
defdelegate query_handle(), to: @configured_cache

## Supervisor callbacks
# These set up the backing cache.
@doc false
defdelegate child_spec(opts), to: @configured_cache
end
Loading