PhoenixKit.Modules.Publishing.ListingCache (PhoenixKitPublishing v0.1.6)

Copy Markdown View Source

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)

Summary

Functions

Returns the timestamp of when the cache was last generated from the database.

Returns the :persistent_term key for tracking when the cache was last generated.

Checks if a cache exists for a group in :persistent_term.

Finds a post by a previous URL slug for 301 redirects.

Finds a post by URL slug for a specific language.

Finds a post by slug in the cache.

Finds a cached post by mode — uses date/time lookup for timestamp mode, slug for others.

Finds a post by path pattern in the cache (for timestamp mode).

Invalidates (clears) the cache for a group.

Loads the cache from the database into :persistent_term.

Returns the :persistent_term key for tracking when the memory cache was loaded.

Returns whether memory caching (:persistent_term) is enabled. Uses cached settings to avoid database queries on every call.

Returns when the memory cache was loaded (ISO 8601 string), or nil if not loaded.

Returns the :persistent_term key for a publishing group's cache.

Reads the cached listing for a publishing group.

Regenerates the listing cache for a group.

Regenerates the cache if no other process is already regenerating it.

Functions

cache_generated_at(group_slug)

@spec cache_generated_at(String.t()) :: String.t() | nil

Returns the timestamp of when the cache was last generated from the database.

cache_generated_at_key(group_slug)

@spec cache_generated_at_key(String.t()) :: tuple()

Returns the :persistent_term key for tracking when the cache was last generated.

exists?(group_slug)

@spec exists?(String.t()) :: boolean()

Checks if a cache exists for a group in :persistent_term.

find_by_previous_url_slug(group_slug, language, url_slug)

@spec find_by_previous_url_slug(String.t(), String.t(), String.t()) ::
  {:ok, map()} | {:error, :not_found | :cache_miss}

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

find_by_url_slug(group_slug, language, url_slug)

@spec find_by_url_slug(String.t(), String.t(), String.t()) ::
  {:ok, map()} | {:error, :not_found | :cache_miss}

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

find_post(group_slug, post_slug)

@spec find_post(String.t(), String.t()) ::
  {:ok, map()} | {:error, :not_found | :cache_miss}

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.

find_post_by_mode(group_slug, post)

@spec find_post_by_mode(String.t(), map()) ::
  {:ok, map()} | {:error, :cache_miss | :not_found}

Finds a cached post by mode — uses date/time lookup for timestamp mode, slug for others.

find_post_by_path(group_slug, date, time)

@spec find_post_by_path(String.t(), String.t(), String.t()) ::
  {:ok, map()} | {:error, :not_found | :cache_miss}

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.

invalidate(group_slug)

@spec invalidate(String.t()) :: :ok

Invalidates (clears) the cache for a group.

Clears the :persistent_term entries. The next read will trigger a regeneration from the database.

load_into_memory(group_slug)

@spec load_into_memory(String.t()) :: :ok | {:error, any()}

Loads the cache from the database into :persistent_term.

Returns :ok if successful or {:error, reason} on failure.

loaded_at_key(group_slug)

@spec loaded_at_key(String.t()) :: tuple()

Returns the :persistent_term key for tracking when the memory cache was loaded.

memory_cache_enabled?()

@spec memory_cache_enabled?() :: boolean()

Returns whether memory caching (:persistent_term) is enabled. Uses cached settings to avoid database queries on every call.

memory_loaded_at(group_slug)

@spec memory_loaded_at(String.t()) :: String.t() | nil

Returns when the memory cache was loaded (ISO 8601 string), or nil if not loaded.

persistent_term_key(group_slug)

@spec persistent_term_key(String.t()) :: tuple()

Returns the :persistent_term key for a publishing group's cache.

read(group_slug)

@spec read(String.t()) :: {:ok, [map()]} | {:error, :cache_miss}

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.

regenerate(group_slug)

@spec regenerate(String.t()) :: :ok | {:error, any()}

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.

regenerate_if_not_in_progress(group_slug)

@spec regenerate_if_not_in_progress(String.t()) ::
  :ok | :already_in_progress | {:error, any()}

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 30000ms 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