Files
2026-02-14 23:58:01 -07:00

258 lines
7.3 KiB
Elixir

defmodule Odinsea.Database.Redis do
@moduledoc """
Redis client for Odinsea.
Provides key-value storage, pub/sub, and caching functionality.
"""
require Logger
# ==================================================================================================
# Connection
# ==================================================================================================
defp conn do
# Get Redis connection from application environment
# In production, this would be a persistent connection pool
host = Application.get_env(:odinsea, :redis_host, "localhost")
port = Application.get_env(:odinsea, :redis_port, 6379)
database = Application.get_env(:odinsea, :redis_database, 0)
password = Application.get_env(:odinsea, :redis_password, nil)
opts = [host: host, port: port, database: database]
opts = if password, do: Keyword.put(opts, :password, password), else: opts
case Redix.start_link(opts) do
{:ok, conn} -> conn
{:error, _} -> nil
end
end
# ==================================================================================================
# Key-Value Operations
# ==================================================================================================
@doc """
Gets a value by key.
"""
@spec get(String.t()) :: {:ok, String.t() | nil} | {:error, term()}
def get(key) do
with conn when not is_nil(conn) <- conn(),
{:ok, value} <- Redix.command(conn, ["GET", key]) do
{:ok, value}
else
nil -> {:error, :no_connection}
{:error, reason} -> {:error, reason}
end
after
close_conn()
end
@doc """
Sets a key to a value.
"""
@spec set(String.t(), String.t()) :: :ok | {:error, term()}
def set(key, value) do
with conn when not is_nil(conn) <- conn(),
{:ok, _} <- Redix.command(conn, ["SET", key, value]) do
:ok
else
nil -> {:error, :no_connection}
{:error, reason} -> {:error, reason}
end
after
close_conn()
end
@doc """
Sets a key to a value with expiration (in seconds).
"""
@spec setex(String.t(), integer(), String.t()) :: :ok | {:error, term()}
def setex(key, seconds, value) do
with conn when not is_nil(conn) <- conn(),
{:ok, _} <- Redix.command(conn, ["SETEX", key, seconds, value]) do
:ok
else
nil -> {:error, :no_connection}
{:error, reason} -> {:error, reason}
end
after
close_conn()
end
@doc """
Deletes a key.
"""
@spec del(String.t()) :: :ok | {:error, term()}
def del(key) do
with conn when not is_nil(conn) <- conn(),
{:ok, _} <- Redix.command(conn, ["DEL", key]) do
:ok
else
nil -> {:error, :no_connection}
{:error, reason} -> {:error, reason}
end
after
close_conn()
end
@doc """
Checks if a key exists.
"""
@spec exists?(String.t()) :: boolean()
def exists?(key) do
with conn when not is_nil(conn) <- conn(),
{:ok, count} <- Redix.command(conn, ["EXISTS", key]) do
count > 0
else
_ -> false
end
after
close_conn()
end
@doc """
Sets expiration on a key (in seconds).
"""
@spec expire(String.t(), integer()) :: :ok | {:error, term()}
def expire(key, seconds) do
with conn when not is_nil(conn) <- conn(),
{:ok, _} <- Redix.command(conn, ["EXPIRE", key, seconds]) do
:ok
else
nil -> {:error, :no_connection}
{:error, reason} -> {:error, reason}
end
after
close_conn()
end
# ==================================================================================================
# Pub/Sub Operations
# ==================================================================================================
@doc """
Publishes a message to a channel.
The message is automatically JSON-encoded.
"""
@spec publish(String.t(), map()) :: :ok | {:error, term()}
def publish(channel, message) do
json_message = Jason.encode!(message)
with conn when not is_nil(conn) <- conn(),
{:ok, _} <- Redix.command(conn, ["PUBLISH", channel, json_message]) do
:ok
else
nil -> {:error, :no_connection}
{:error, reason} -> {:error, reason}
end
after
close_conn()
end
@doc """
Subscribes to channels and handles messages with a callback function.
This is a blocking operation that should be run in a separate process.
"""
@spec subscribe([String.t()], (String.t(), map() -> any())) :: :ok | {:error, term()}
def subscribe(channels, callback) when is_list(channels) and is_function(callback, 2) do
# Pub/Sub in Redix requires a separate connection
host = Application.get_env(:odinsea, :redis_host, "localhost")
port = Application.get_env(:odinsea, :redis_port, 6379)
opts = [host: host, port: port]
case Redix.PubSub.start_link(opts) do
{:ok, pubsub} ->
# Subscribe to channels
Enum.each(channels, fn channel ->
Redix.PubSub.subscribe(pubsub, channel, self())
end)
# Message loop
message_loop(pubsub, callback)
{:error, reason} ->
{:error, reason}
end
end
defp message_loop(pubsub, callback) do
receive do
{:redix_pubsub, ^pubsub, :message, %{channel: channel, payload: payload}} ->
# Decode JSON payload
case Jason.decode(payload) do
{:ok, decoded} -> callback.(channel, decoded)
{:error, _} -> callback.(channel, %{"raw" => payload})
end
message_loop(pubsub, callback)
{:redix_pubsub, ^pubsub, :subscribed, %{channel: _channel}} ->
message_loop(pubsub, callback)
_ ->
message_loop(pubsub, callback)
end
end
# ==================================================================================================
# Helper Functions
# ==================================================================================================
defp close_conn do
# Note: In a production setup with connection pooling,
# this would return the connection to the pool instead of closing
:ok
end
@doc """
Gets the online count for a world.
"""
@spec get_world_online_count(integer()) :: integer()
def get_world_online_count(world_id) do
case get("world:#{world_id}:online_count") do
{:ok, nil} -> 0
{:ok, count} -> String.to_integer(count)
{:error, _} -> 0
end
end
@doc """
Updates the online count for a world.
"""
@spec update_world_online_count(integer(), integer()) :: :ok
def update_world_online_count(world_id, count) do
set("world:#{world_id}:online_count", to_string(count))
:ok
end
@doc """
Registers a player as online.
"""
@spec register_player_online(integer(), integer(), String.t()) :: :ok
def register_player_online(character_id, world_id, channel) do
setex("player:#{character_id}:online", 60, Jason.encode!(%{
world_id: world_id,
channel: channel,
timestamp: System.system_time(:second)
}))
:ok
end
@doc """
Unregisters a player as online.
"""
@spec unregister_player_online(integer()) :: :ok
def unregister_player_online(character_id) do
del("player:#{character_id}:online")
:ok
end
@doc """
Checks if a player is online.
"""
@spec player_online?(integer()) :: boolean()
def player_online?(character_id) do
exists?("player:#{character_id}:online")
end
end