mirror of
https://github.com/wanderer-industries/wanderer
synced 2025-12-12 02:35:42 +00:00
125 lines
3.6 KiB
Elixir
125 lines
3.6 KiB
Elixir
defmodule WandererApp.Api.Changes.SlugifyName do
|
|
@moduledoc """
|
|
Ensures map slugs are unique by:
|
|
1. Slugifying the provided slug/name
|
|
2. Checking for existing slugs (optimization)
|
|
3. Finding next available slug with numeric suffix if needed
|
|
4. Relying on database unique constraint as final arbiter
|
|
|
|
Race Condition Mitigation:
|
|
- Optimistic check reduces DB roundtrips for most cases
|
|
- Database unique index ensures no duplicates slip through
|
|
- Proper error messages for constraint violations
|
|
- Telemetry events for monitoring conflicts
|
|
"""
|
|
use Ash.Resource.Change
|
|
|
|
alias Ash.Changeset
|
|
require Ash.Query
|
|
require Logger
|
|
|
|
# Maximum number of attempts to find a unique slug
|
|
@max_attempts 100
|
|
|
|
@impl true
|
|
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
|
|
def change(changeset, _options, _context) do
|
|
Changeset.before_action(changeset, &maybe_slugify_name/1)
|
|
end
|
|
|
|
defp maybe_slugify_name(changeset) do
|
|
case Changeset.get_attribute(changeset, :slug) do
|
|
slug when is_binary(slug) ->
|
|
base_slug = Slug.slugify(slug)
|
|
unique_slug = ensure_unique_slug(changeset, base_slug)
|
|
Changeset.force_change_attribute(changeset, :slug, unique_slug)
|
|
|
|
_ ->
|
|
changeset
|
|
end
|
|
end
|
|
|
|
defp ensure_unique_slug(changeset, base_slug) do
|
|
# Get the current record ID if this is an update operation
|
|
current_id = Changeset.get_attribute(changeset, :id)
|
|
|
|
# Check if the base slug is available (optimization to avoid numeric suffixes when possible)
|
|
if slug_available?(base_slug, current_id) do
|
|
base_slug
|
|
else
|
|
# Find the next available slug with a numeric suffix
|
|
find_available_slug(base_slug, current_id, 2)
|
|
end
|
|
end
|
|
|
|
defp find_available_slug(base_slug, current_id, n) when n <= @max_attempts do
|
|
candidate_slug = "#{base_slug}-#{n}"
|
|
|
|
if slug_available?(candidate_slug, current_id) do
|
|
# Emit telemetry when we had to use a suffix (indicates potential conflict)
|
|
:telemetry.execute(
|
|
[:wanderer_app, :map, :slug_suffix_used],
|
|
%{suffix_number: n},
|
|
%{base_slug: base_slug, final_slug: candidate_slug}
|
|
)
|
|
|
|
candidate_slug
|
|
else
|
|
find_available_slug(base_slug, current_id, n + 1)
|
|
end
|
|
end
|
|
|
|
defp find_available_slug(base_slug, _current_id, n) when n > @max_attempts do
|
|
# Fallback: use timestamp suffix if we've tried too many numeric suffixes
|
|
# This handles edge cases where many maps have similar names
|
|
timestamp = System.system_time(:millisecond)
|
|
fallback_slug = "#{base_slug}-#{timestamp}"
|
|
|
|
Logger.warning(
|
|
"Slug generation exceeded #{@max_attempts} attempts for '#{base_slug}', using timestamp fallback",
|
|
base_slug: base_slug,
|
|
fallback_slug: fallback_slug
|
|
)
|
|
|
|
:telemetry.execute(
|
|
[:wanderer_app, :map, :slug_fallback_used],
|
|
%{attempts: n},
|
|
%{base_slug: base_slug, fallback_slug: fallback_slug}
|
|
)
|
|
|
|
fallback_slug
|
|
end
|
|
|
|
defp slug_available?(slug, current_id) do
|
|
query =
|
|
WandererApp.Api.Map
|
|
|> Ash.Query.filter(slug == ^slug)
|
|
|> then(fn query ->
|
|
# Exclude the current record if this is an update
|
|
if current_id do
|
|
Ash.Query.filter(query, id != ^current_id)
|
|
else
|
|
query
|
|
end
|
|
end)
|
|
|> Ash.Query.limit(1)
|
|
|
|
case Ash.read(query) do
|
|
{:ok, []} ->
|
|
true
|
|
|
|
{:ok, _existing} ->
|
|
false
|
|
|
|
{:error, error} ->
|
|
# Log error but be conservative - assume slug is not available
|
|
Logger.warning("Error checking slug availability",
|
|
slug: slug,
|
|
error: inspect(error)
|
|
)
|
|
|
|
false
|
|
end
|
|
end
|
|
end
|