Current section

Files

Jump to
phoenix_kit_publishing lib phoenix_kit_publishing listing_cache.ex
Raw

lib/phoenix_kit_publishing/listing_cache.ex

defmodule PhoenixKit.Modules.Publishing.ListingCache do
@moduledoc """
Caches publishing group listing metadata in :persistent_term for sub-millisecond reads.
Instead of querying the database on every request, the listing page reads from
an in-memory cache populated from the database.
## How It Works
1. When a post is created/updated/published, `regenerate/1` is called
2. This queries the database and stores post metadata in :persistent_term
3. `render_group_listing` reads from the in-memory cache
4. Cache includes: title, slug, date, status, languages, versions (no content)
## Performance
- Cache miss: ~20ms (DB query + store in :persistent_term)
- Cache hit: ~0.1μs (direct memory access, no variance)
## Cache Invalidation
Cache is regenerated when:
- Post is created
- Post is updated (metadata or content)
- Post status changes (draft/published/archived)
- Translation is added
- Version is created
## In-Memory Caching with :persistent_term
For sub-millisecond performance, parsed cache data is stored in `:persistent_term`.
- First read after restart: queries DB, stores in :persistent_term (~20ms)
- Subsequent reads: direct memory access (~0.1μs, no variance)
- On regenerate: updates :persistent_term from DB
- On invalidate: clears :persistent_term entry (next read triggers regeneration)
"""
alias PhoenixKit.Modules.Publishing.Constants
alias PhoenixKit.Modules.Publishing.DBStorage
@timestamp_modes Constants.timestamp_modes()
alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Date, as: UtilsDate
require Logger
@persistent_term_prefix :phoenix_kit_group_listing_cache
@persistent_term_loaded_at_prefix :phoenix_kit_group_listing_cache_loaded_at
@persistent_term_cache_generated_at_prefix :phoenix_kit_group_listing_cache_generated_at
# ETS table for regeneration locks (provides atomic test-and-set via insert_new)
@lock_table :phoenix_kit_listing_cache_locks
# Settings key for memory cache toggle
@memory_cache_key "publishing_memory_cache_enabled"
@doc """
Reads the cached listing for a publishing group.
Returns `{:ok, posts}` if cache exists and is valid.
Returns `{:error, :cache_miss}` if cache doesn't exist or caching is disabled.
Respects the `publishing_memory_cache_enabled` setting.
"""
@spec read(String.t()) :: {:ok, [map()]} | {:error, :cache_miss}
def read(group_slug) do
if memory_cache_enabled?() do
read_from_cache(group_slug)
else
{:error, :cache_miss}
end
end
defp read_from_cache(group_slug) do
term_key = persistent_term_key(group_slug)
case safe_persistent_term_get(term_key) do
{:ok, _} = hit ->
hit
:not_found ->
regenerate(group_slug)
read_after_regenerate(term_key)
end
end
defp read_after_regenerate(term_key) do
case safe_persistent_term_get(term_key) do
{:ok, _} = hit -> hit
:not_found -> {:error, :cache_miss}
end
end
# Safely get from :persistent_term (returns :not_found instead of raising)
defp safe_persistent_term_get(key) do
{:ok, :persistent_term.get(key)}
rescue
ArgumentError -> :not_found
end
@doc """
Regenerates the listing cache for a group.
Queries the database for all posts and stores the metadata in :persistent_term.
This should be called after any post operation that changes the listing:
- create_post
- update_post
- add_language_to_post
- create_new_version
Returns `:ok` on success or `{:error, reason}` on failure.
"""
@spec regenerate(String.t()) :: :ok | {:error, any()}
def regenerate(group_slug) do
if memory_cache_enabled?() do
do_regenerate(group_slug)
else
:ok
end
rescue
error ->
Logger.error(
"[ListingCache] Failed to regenerate cache for #{group_slug}: #{inspect(error)}"
)
{:error, {:regenerate_failed, error}}
end
# Maximum number of posts to cache in :persistent_term per group.
# Groups exceeding this will still work but only cache the most recent posts.
@max_cached_posts 5000
defp do_regenerate(group_slug) do
start_time = System.monotonic_time(:millisecond)
# Posts from to_listing_map are already atom-key maps with excerpts
all_posts = DBStorage.list_posts_for_listing(group_slug)
posts =
if length(all_posts) > @max_cached_posts do
Logger.warning(
"[ListingCache] Group #{group_slug} has #{length(all_posts)} posts, caching most recent #{@max_cached_posts}"
)
Enum.take(all_posts, @max_cached_posts)
else
all_posts
end
generated_at = UtilsDate.utc_now() |> DateTime.to_iso8601()
safe_persistent_term_put(persistent_term_key(group_slug), posts)
safe_persistent_term_put(loaded_at_key(group_slug), generated_at)
safe_persistent_term_put(cache_generated_at_key(group_slug), generated_at)
elapsed = System.monotonic_time(:millisecond) - start_time
Logger.debug(
"[ListingCache] Regenerated cache from DB for #{group_slug} (#{length(posts)} posts) in #{elapsed}ms"
)
PublishingPubSub.broadcast_cache_changed(group_slug)
:ok
rescue
error ->
Logger.error(
"[ListingCache] Failed to regenerate cache for #{group_slug}: #{inspect(error)}"
)
{:error, {:regenerate_failed, error}}
end
# Lock timeout in milliseconds (30 seconds)
# If a lock is older than this, it's considered stale (process likely died)
@lock_timeout_ms 30_000
@doc """
Regenerates the cache if no other process is already regenerating it.
This prevents the "thundering herd" problem where multiple concurrent requests
all trigger cache regeneration simultaneously after a server restart.
Uses ETS with `insert_new/2` for atomic lock acquisition - only one process
can acquire the lock at a time. The lock includes a timestamp and will be
considered stale after #{@lock_timeout_ms}ms to prevent permanent lockout
if a process dies mid-regeneration.
Returns:
- `:ok` if regeneration was performed successfully
- `:already_in_progress` if another process is currently regenerating
- `{:error, reason}` if regeneration failed
## Usage
On cache miss in read paths, use this instead of `regenerate/1`:
case ListingCache.regenerate_if_not_in_progress(group_slug) do
:ok -> # Cache is ready, read from it
:already_in_progress -> # Another process is regenerating, try again later
{:error, _} -> # Regeneration failed, query DB directly
end
"""
@spec regenerate_if_not_in_progress(String.t()) :: :ok | :already_in_progress | {:error, any()}
def regenerate_if_not_in_progress(group_slug) do
ensure_lock_table_exists()
now = System.monotonic_time(:millisecond)
# Try to atomically acquire the lock using ETS insert_new
# Returns true if inserted (lock acquired), false if key already exists
case :ets.insert_new(@lock_table, {group_slug, now}) do
true ->
# We acquired the lock - perform regeneration
do_regenerate_with_lock(group_slug)
false ->
# Lock exists - check if it's stale
handle_existing_lock(group_slug, now)
end
end
# Handle case where lock already exists - check staleness
defp handle_existing_lock(group_slug, now) do
case :ets.lookup(@lock_table, group_slug) do
[{^group_slug, lock_timestamp}] ->
lock_age = now - lock_timestamp
if lock_age < @lock_timeout_ms do
# Lock is valid and recent - another process is regenerating
Logger.debug(
"[ListingCache] Regeneration already in progress for #{group_slug} (#{lock_age}ms ago), skipping"
)
:already_in_progress
else
# Lock is stale - previous process likely died
# Try to take over by deleting and re-acquiring atomically
take_over_stale_lock(group_slug, lock_timestamp, lock_age, now)
end
[] ->
# Lock was released between insert_new and lookup - try again
regenerate_if_not_in_progress(group_slug)
end
end
# Attempt to take over a stale lock using compare-and-delete
defp take_over_stale_lock(group_slug, old_timestamp, lock_age, now) do
# Use match_delete for atomic compare-and-delete
# Only deletes if the timestamp matches (no one else took over)
case :ets.select_delete(@lock_table, [{{group_slug, old_timestamp}, [], [true]}]) do
1 ->
# Successfully deleted stale lock - now try to acquire
Logger.warning(
"[ListingCache] Found stale lock for #{group_slug} (#{lock_age}ms old), taking over regeneration"
)
case :ets.insert_new(@lock_table, {group_slug, now}) do
true ->
do_regenerate_with_lock(group_slug)
false ->
# Another process beat us to it
:already_in_progress
end
0 ->
# Lock was already taken over by another process or timestamp changed
:already_in_progress
end
end
# Perform regeneration while holding the lock
defp do_regenerate_with_lock(group_slug) do
result = regenerate(group_slug)
case result do
:ok -> :ok
{:error, _} = error -> error
end
after
# Always release the lock when done (success or failure)
:ets.delete(@lock_table, group_slug)
end
# Ensure the ETS table for locks exists (lazy initialization)
defp ensure_lock_table_exists do
case :ets.whereis(@lock_table) do
:undefined ->
# Table doesn't exist - create it
# Use :public so any process can read/write
# Use :named_table so we can reference by atom
# Use :set for key-value storage
try do
:ets.new(@lock_table, [:set, :public, :named_table])
rescue
ArgumentError ->
# Table was created by another process between whereis and new
:ok
end
_tid ->
:ok
end
end
# Safely put to :persistent_term (logs warning on failure instead of crashing)
defp safe_persistent_term_put(key, value) do
:persistent_term.put(key, value)
rescue
error ->
Logger.warning("[ListingCache] Failed to write to :persistent_term: #{inspect(error)}")
:error
end
@doc """
Loads the cache from the database into :persistent_term.
Returns `:ok` if successful or `{:error, reason}` on failure.
"""
@spec load_into_memory(String.t()) :: :ok | {:error, any()}
def load_into_memory(group_slug) do
load_into_memory_from_db(group_slug)
end
defp load_into_memory_from_db(group_slug) do
posts = DBStorage.list_posts_for_listing(group_slug)
generated_at = UtilsDate.utc_now() |> DateTime.to_iso8601()
safe_persistent_term_put(persistent_term_key(group_slug), posts)
safe_persistent_term_put(loaded_at_key(group_slug), generated_at)
safe_persistent_term_put(cache_generated_at_key(group_slug), generated_at)
Logger.debug(
"[ListingCache] Loaded #{group_slug} from DB into :persistent_term (#{length(posts)} posts)"
)
PublishingPubSub.broadcast_cache_changed(group_slug)
:ok
rescue
error ->
Logger.error("[ListingCache] Failed to load #{group_slug} from DB: #{inspect(error)}")
{:error, {:load_failed, error}}
end
@doc """
Invalidates (clears) the cache for a group.
Clears the :persistent_term entries. The next read will trigger
a regeneration from the database.
"""
@spec invalidate(String.t()) :: :ok
def invalidate(group_slug) do
# Clear :persistent_term entries
term_key = persistent_term_key(group_slug)
try do
:persistent_term.erase(term_key)
rescue
ArgumentError -> :ok
end
try do
:persistent_term.erase(loaded_at_key(group_slug))
rescue
ArgumentError -> :ok
end
try do
:persistent_term.erase(cache_generated_at_key(group_slug))
rescue
ArgumentError -> :ok
end
Logger.debug("[ListingCache] Invalidated cache for #{group_slug}")
:ok
end
@doc """
Checks if a cache exists for a group in :persistent_term.
"""
@spec exists?(String.t()) :: boolean()
def exists?(group_slug) do
case safe_persistent_term_get(persistent_term_key(group_slug)) do
{:ok, _} -> true
:not_found -> false
end
end
@doc """
Finds a post by slug in the cache.
This is useful for single post views where we need metadata (language_statuses,
version_statuses, allow_version_access) without a separate DB query.
Returns `{:ok, cached_post}` if found, `{:error, :not_found}` otherwise.
"""
@spec find_post(String.t(), String.t()) :: {:ok, map()} | {:error, :not_found | :cache_miss}
def find_post(group_slug, post_slug) do
with {:ok, posts} <- read(group_slug) do
find_in_posts_by_slug(posts, post_slug)
end
end
defp find_in_posts_by_slug(posts, post_slug) do
case Enum.find(posts, fn p -> p.slug == post_slug end) do
nil -> {:error, :not_found}
post -> {:ok, post}
end
end
@doc """
Finds a post by path pattern in the cache (for timestamp mode).
Matches posts where the path contains the date/time pattern.
Returns `{:ok, cached_post}` if found, `{:error, :not_found}` otherwise.
"""
@spec find_post_by_path(String.t(), String.t(), String.t()) ::
{:ok, map()} | {:error, :not_found | :cache_miss}
def find_post_by_path(group_slug, date, time) do
with {:ok, posts} <- read(group_slug) do
find_post_by_date_time(posts, date, time)
end
end
defp find_post_by_date_time(posts, date, time) do
target_date = parse_date_for_lookup(date)
target_time = normalize_time_for_lookup(time)
case Enum.find(posts, fn p ->
dates_match?(p.date, target_date) && times_match?(p.time, target_time)
end) do
nil -> {:error, :not_found}
post -> {:ok, post}
end
end
# Parse date string for lookup comparison
defp parse_date_for_lookup(date_str) when is_binary(date_str) do
case Date.from_iso8601(date_str) do
{:ok, date} -> date
_ -> date_str
end
end
defp parse_date_for_lookup(date), do: date
# Normalize time to "HH:MM" format for comparison
defp normalize_time_for_lookup(time_str) when is_binary(time_str) do
# Take just HH:MM portion
String.slice(time_str, 0, 5)
end
defp normalize_time_for_lookup(time), do: time
# Compare dates - handles both Date structs and strings
defp dates_match?(nil, _), do: false
defp dates_match?(_, nil), do: false
defp dates_match?(%Date{} = cached, %Date{} = target) do
Date.compare(cached, target) == :eq
end
defp dates_match?(%Date{} = cached, target_str) when is_binary(target_str) do
Date.to_iso8601(cached) == target_str
end
defp dates_match?(_, _), do: false
# Compare times - handles Time structs and "HH:MM" strings
defp times_match?(nil, _), do: false
defp times_match?(_, nil), do: false
defp times_match?(%Time{} = cached, target_str) when is_binary(target_str) do
# Format cached time as HH:MM and compare
cached_str = cached |> Time.to_string() |> String.slice(0, 5)
cached_str == target_str
end
defp times_match?(cached_str, target_str)
when is_binary(cached_str) and is_binary(target_str) do
String.slice(cached_str, 0, 5) == String.slice(target_str, 0, 5)
end
defp times_match?(_, _), do: false
@doc """
Finds a post by URL slug for a specific language.
This enables O(1) lookup from URL slug to internal identifier, supporting
per-language URL slugs for SEO-friendly localized URLs.
## Parameters
- `group_slug` - The publishing group
- `language` - The language code to search in
- `url_slug` - The URL slug to find
## Returns
- `{:ok, cached_post}` - Found post (includes internal `slug` for DB lookup)
- `{:error, :not_found}` - No post with this URL slug for this language
- `{:error, :cache_miss}` - Cache not available
"""
@spec find_by_url_slug(String.t(), String.t(), String.t()) ::
{:ok, map()} | {:error, :not_found | :cache_miss}
def find_by_url_slug(group_slug, language, url_slug) do
case read(group_slug) do
{:ok, posts} -> find_post_by_url_slug(posts, language, url_slug)
{:error, _} -> {:error, :cache_miss}
end
end
defp find_post_by_url_slug(posts, language, url_slug) do
case Enum.find(posts, &(Map.get(&1.language_slugs || %{}, language) == url_slug)) do
nil -> {:error, :not_found}
post -> {:ok, post}
end
end
@doc """
Finds a post by a previous URL slug for 301 redirects.
When a URL slug changes, the old slug is stored in `previous_url_slugs`.
This function finds posts that previously used the given URL slug.
## Returns
- `{:ok, cached_post}` - Found post that previously used this slug
- `{:error, :not_found}` - No post with this previous slug
- `{:error, :cache_miss}` - Cache not available
"""
@spec find_by_previous_url_slug(String.t(), String.t(), String.t()) ::
{:ok, map()} | {:error, :not_found | :cache_miss}
def find_by_previous_url_slug(group_slug, language, url_slug) do
case read(group_slug) do
{:ok, posts} -> find_post_by_previous_slug(posts, language, url_slug)
{:error, _} -> {:error, :cache_miss}
end
end
defp find_post_by_previous_slug(posts, language, url_slug) do
case Enum.find(posts, &post_has_previous_slug?(&1, language, url_slug)) do
nil -> {:error, :not_found}
post -> {:ok, post}
end
end
defp post_has_previous_slug?(post, language, url_slug) do
lang_previous_slugs = Map.get(post, :language_previous_slugs) || %{}
previous_for_lang = Map.get(lang_previous_slugs, language) || []
url_slug in previous_for_lang
end
@doc """
Finds a cached post by mode — uses date/time lookup for timestamp mode, slug for others.
"""
@spec find_post_by_mode(String.t(), map()) ::
{:ok, map()} | {:error, :cache_miss | :not_found}
def find_post_by_mode(group_slug, post) do
mode = Map.get(post, :mode)
if mode in @timestamp_modes do
find_post_by_timestamp_mode(group_slug, post)
else
find_post(group_slug, post.slug)
end
end
defp find_post_by_timestamp_mode(group_slug, post) do
date = post[:date]
time = post[:time]
if date && time do
date_str = if is_struct(date, Date), do: Date.to_iso8601(date), else: to_string(date)
time_str = format_time_for_cache(time)
find_post_by_path(group_slug, date_str, time_str)
else
{:error, :not_found}
end
end
defp format_time_for_cache(%Time{} = time) do
time |> Time.to_string() |> String.slice(0, 5)
end
defp format_time_for_cache(time) when is_binary(time), do: String.slice(time, 0, 5)
defp format_time_for_cache(_), do: ""
@doc """
Returns the :persistent_term key for a publishing group's cache.
"""
@spec persistent_term_key(String.t()) :: tuple()
def persistent_term_key(group_slug) do
{@persistent_term_prefix, group_slug}
end
@doc """
Returns the :persistent_term key for tracking when the memory cache was loaded.
"""
@spec loaded_at_key(String.t()) :: tuple()
def loaded_at_key(group_slug) do
{@persistent_term_loaded_at_prefix, group_slug}
end
@doc """
Returns when the memory cache was loaded (ISO 8601 string), or nil if not loaded.
"""
@spec memory_loaded_at(String.t()) :: String.t() | nil
def memory_loaded_at(group_slug) do
case safe_persistent_term_get(loaded_at_key(group_slug)) do
{:ok, loaded_at} -> loaded_at
:not_found -> nil
end
end
@doc """
Returns the :persistent_term key for tracking when the cache was last generated.
"""
@spec cache_generated_at_key(String.t()) :: tuple()
def cache_generated_at_key(group_slug) do
{@persistent_term_cache_generated_at_prefix, group_slug}
end
@doc """
Returns the timestamp of when the cache was last generated from the database.
"""
@spec cache_generated_at(String.t()) :: String.t() | nil
def cache_generated_at(group_slug) do
case safe_persistent_term_get(cache_generated_at_key(group_slug)) do
{:ok, generated_at} -> generated_at
:not_found -> nil
end
end
@doc """
Returns whether memory caching (:persistent_term) is enabled.
Uses cached settings to avoid database queries on every call.
"""
@spec memory_cache_enabled?() :: boolean()
def memory_cache_enabled? do
Settings.get_setting_cached(@memory_cache_key, "true") == "true"
end
end