Packages

phoenix_kit

2.60.2
2.62.2 2.62.1 2.62.0 2.61.0 2.60.3 2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules sitemap scheduler_worker.ex
Raw

lib/modules/sitemap/scheduler_worker.ex

defmodule PhoenixKit.Modules.Sitemap.SchedulerWorker do
  @moduledoc """
  Oban worker for scheduled sitemap regeneration.

  This worker is responsible for:
  - Periodic sitemap regeneration based on configured interval
  - Automatic re-scheduling after each run
  - Checking if scheduling is enabled before execution

  ## Configuration

  Scheduling is controlled via Settings:
  - `sitemap_schedule_enabled` - Enable/disable automatic regeneration
  - `sitemap_schedule_interval_hours` - Interval between regenerations (default: 24)

  ## Usage

      # Schedule initial job (called when schedule is enabled)
      PhoenixKit.Modules.Sitemap.SchedulerWorker.schedule()

      # Manual trigger
      PhoenixKit.Modules.Sitemap.SchedulerWorker.regenerate_now()

      # Cancel scheduled jobs
      PhoenixKit.Modules.Sitemap.SchedulerWorker.cancel_scheduled()

  ## Oban Queue

  Jobs are placed in the `:sitemap` queue with max 3 attempts.
  """

  use Oban.Worker, queue: :sitemap, max_attempts: 3

  import Ecto.Query

  require Logger

  alias PhoenixKit.Modules.Sitemap
  alias PhoenixKit.Modules.Sitemap.FileStorage
  alias PhoenixKit.Modules.Sitemap.Generator
  alias PhoenixKit.PubSub.Manager, as: PubSubManager
  alias PhoenixKit.Settings
  alias PhoenixKit.Utils.Date, as: UtilsDate

  @doc """
  Performs sitemap regeneration.

  This callback is invoked by Oban when the scheduled job runs.
  It checks if scheduling is still enabled before regenerating.
  """
  @impl Oban.Worker
  def perform(%Oban.Job{args: args}) do
    is_manual = args["manual"] == true

    if is_manual do
      Logger.info("SitemapSchedulerWorker: Starting manual regeneration")
    else
      Logger.info("SitemapSchedulerWorker: Starting scheduled regeneration")
    end

    result =
      cond do
        # Sitemap module must be enabled for any regeneration
        not Sitemap.enabled?() ->
          Logger.info("SitemapSchedulerWorker: Sitemap module disabled, skipping regeneration")
          :ok

        # Schedule check only applies to scheduled jobs, not manual ones
        not is_manual and not schedule_enabled?() ->
          Logger.info("SitemapSchedulerWorker: Scheduling disabled, skipping regeneration")
          :ok

        not valid_base_url?() ->
          Logger.warning("SitemapSchedulerWorker: Base URL not configured, skipping regeneration")
          :ok

        true ->
          do_perform_regeneration(args)
      end

    # The chain must survive a skipped or failed run: a scheduled job that
    # returned without rescheduling (base URL briefly empty, one transient
    # generation error) used to stop regeneration until the next boot's
    # `ensure_scheduled/0`. Scheduling still enabled → keep the chain alive.
    if args["scheduled"] and schedule_enabled?() and Sitemap.enabled?() do
      schedule_next()
    end

    result
  end

  defp valid_base_url? do
    base_url = Sitemap.get_base_url()
    base_url != ""
  end

  defp do_perform_regeneration(args) do
    base_url = Sitemap.get_base_url()

    result =
      case args["source"] do
        nil ->
          # Full regeneration
          regenerate_sitemap(base_url)

        source_name ->
          # Per-module regeneration: regenerate one source, rebuild index
          Logger.info("SitemapSchedulerWorker: Regenerating module '#{source_name}'")
          regenerate_sitemap(base_url)
      end

    case result do
      :ok ->
        Logger.info("SitemapSchedulerWorker: Regeneration completed successfully")
        :ok

      {:error, reason} ->
        Logger.error("SitemapSchedulerWorker: Regeneration failed: #{inspect(reason)}")
        {:error, reason}
    end
  end

  @impl Oban.Worker
  def timeout(_job), do: :timer.minutes(10)

  @doc """
  Schedules the next sitemap regeneration.

  The job is scheduled based on `sitemap_schedule_interval_hours` setting.
  If scheduling is disabled, no job is created.

  ## Options

  - `:delay_hours` - Override the configured interval (optional)

  ## Examples

      # Schedule with configured interval
      PhoenixKit.Modules.Sitemap.SchedulerWorker.schedule()

      # Schedule with custom delay
      PhoenixKit.Modules.Sitemap.SchedulerWorker.schedule(delay_hours: 1)
  """
  @spec schedule(keyword()) :: {:ok, Oban.Job.t()} | {:error, term()} | :disabled
  def schedule(opts \\ []) do
    if schedule_enabled?() do
      delay_hours = Keyword.get(opts, :delay_hours) || get_interval_hours()
      scheduled_at = DateTime.add(UtilsDate.utc_now(), delay_hours * 3600, :second)

      %{scheduled: true}
      |> new(scheduled_at: scheduled_at)
      |> insert_job()
    else
      Logger.info("SitemapSchedulerWorker: Scheduling disabled, job not created")
      :disabled
    end
  end

  @doc """
  Schedules the next regeneration after current job completes.
  """
  @spec schedule_next() :: {:ok, Oban.Job.t()} | {:error, term()} | :disabled
  def schedule_next do
    schedule()
  end

  @doc """
  Triggers immediate sitemap regeneration.

  This creates a job that runs immediately, bypassing the schedule.
  """
  @spec regenerate_now() :: {:ok, Oban.Job.t()} | {:error, term()}
  def regenerate_now do
    Logger.info("SitemapSchedulerWorker: Manual regeneration triggered")

    %{scheduled: false, manual: true}
    |> new()
    |> insert_job()
  end

  @doc """
  Ensures a scheduled job exists when scheduling is enabled.

  Called on application startup to recover the scheduling chain if it was
  broken by a server restart, Oban pruning, or job failure.
  """
  @spec ensure_scheduled() ::
          {:ok, Oban.Job.t()} | {:error, term()} | :disabled | :already_scheduled
  def ensure_scheduled do
    if schedule_enabled?() and count_pending_jobs() == 0 do
      Logger.info("SitemapSchedulerWorker: No pending jobs found on startup, scheduling")
      schedule()
    else
      if schedule_enabled?(), do: :already_scheduled, else: :disabled
    end
  end

  # Future: Per-module event-driven regeneration pattern:
  # When a specific source's content changes (e.g., new product, new post),
  # call regenerate_module_now/1 to regenerate only that source's sitemap
  # and rebuild the index, instead of regenerating everything.

  @doc """
  Triggers immediate regeneration for a specific source module.

  Regenerates only the specified source's sitemap file and rebuilds the index.
  """
  @spec regenerate_module_now(String.t()) :: {:ok, Oban.Job.t()} | {:error, term()}
  def regenerate_module_now(source_name) when is_binary(source_name) do
    Logger.info("SitemapSchedulerWorker: Manual module regeneration triggered for #{source_name}")

    %{scheduled: false, manual: true, source: source_name}
    |> new()
    |> insert_job()
  end

  @doc """
  Ensures sitemap file exists on application startup.

  With file-only architecture, this is optional - sitemap will be generated
  on first request if file doesn't exist. Use this for explicit pre-warming.

  ## Returns

  - `{:ok, job}` - Regeneration job scheduled (file doesn't exist)
  - `:file_exists` - Sitemap file already exists
  - `:disabled` - Sitemap module is disabled

  ## Examples

      # In Application.start/2 or supervisor child
      PhoenixKit.Modules.Sitemap.SchedulerWorker.ensure_cache_warm()
  """
  @spec ensure_cache_warm() ::
          {:ok, Oban.Job.t()} | :file_exists | :disabled | {:error, term()}
  def ensure_cache_warm do
    cond do
      not Sitemap.enabled?() ->
        Logger.debug("SitemapSchedulerWorker: Sitemap disabled, skipping warm-up")
        :disabled

      FileStorage.exists?() ->
        Logger.debug("SitemapSchedulerWorker: Sitemap file already exists")
        :file_exists

      true ->
        Logger.info("SitemapSchedulerWorker: No sitemap file, triggering generation")
        regenerate_now()
    end
  end

  @doc """
  Cancels all scheduled sitemap jobs.

  This is called when scheduling is disabled.
  """
  @spec cancel_scheduled() :: {:ok, non_neg_integer()}
  def cancel_scheduled do
    # Cancel all pending/scheduled jobs for this worker
    worker_name = inspect(__MODULE__)

    {count, _} =
      Oban.Job
      |> where([j], j.worker == ^worker_name)
      |> where([j], j.state in ["available", "scheduled"])
      |> oban_repo_delete_all()

    Logger.info("SitemapSchedulerWorker: Cancelled #{count} scheduled jobs")
    {:ok, count}
  end

  @doc """
  Returns the current scheduling status and next run time.
  """
  @spec status() :: map()
  def status do
    worker_name = inspect(__MODULE__)

    next_job =
      Oban.Job
      |> where([j], j.worker == ^worker_name)
      |> where([j], j.state in ["available", "scheduled"])
      |> order_by([j], asc: j.scheduled_at)
      |> limit(1)
      |> oban_repo_one()

    %{
      enabled: schedule_enabled?(),
      interval_hours: get_interval_hours(),
      next_run: if(next_job, do: next_job.scheduled_at, else: nil),
      pending_jobs: count_pending_jobs()
    }
  end

  # Private functions

  defp regenerate_sitemap(base_url) do
    opts = [base_url: base_url, xsl_style: "table", xsl_enabled: true]

    case Generator.generate_all(opts) do
      {:ok, %{total_urls: url_count, modules: modules}} ->
        # Stats updates are best-effort - broadcast must happen even if stats fail
        try do
          Sitemap.update_generation_stats(%{url_count: url_count})
          Sitemap.update_module_stats(modules)
        rescue
          error ->
            Logger.warning("SitemapSchedulerWorker: Stats update failed: #{inspect(error)}")
        end

        broadcast_sitemap_generated(url_count)
        :ok

      {:error, reason} ->
        {:error, reason}
    end
  end

  # Broadcast sitemap generation completion for real-time UI updates
  defp broadcast_sitemap_generated(url_count) do
    PubSubManager.broadcast(
      "sitemap:updates",
      {:sitemap_generated, %{url_count: url_count, timestamp: UtilsDate.utc_now()}}
    )
  rescue
    # PubSub may not be available in all environments
    _ -> :ok
  end

  defp schedule_enabled? do
    Settings.get_boolean_setting("sitemap_schedule_enabled", false)
  end

  defp get_interval_hours do
    case Settings.get_setting("sitemap_schedule_interval_hours", "24") do
      value when is_binary(value) ->
        case Integer.parse(value) do
          {hours, _} when hours > 0 -> hours
          _ -> 24
        end

      value when is_integer(value) and value > 0 ->
        value

      _ ->
        24
    end
  end

  # Through Oban, not the repo: `Oban.Job` carries no `@schema_prefix`, so a
  # raw `Repo.insert` lands in `public.oban_jobs` on a prefixed install — a
  # table no Oban instance polls. Oban.insert also fires the insert
  # notification so the job is picked up without waiting for the poller.
  defp insert_job(changeset) do
    case Oban.insert(changeset) do
      {:ok, job} ->
        Logger.info("SitemapSchedulerWorker: Job scheduled for #{job.scheduled_at}")
        {:ok, job}

      {:error, reason} ->
        Logger.error("SitemapSchedulerWorker: Failed to schedule job: #{inspect(reason)}")
        {:error, reason}
    end
  end

  defp count_pending_jobs do
    worker_name = inspect(__MODULE__)

    Oban.Job
    |> where([j], j.worker == ^worker_name)
    |> where([j], j.state in ["available", "scheduled"])
    |> oban_repo_aggregate(:count)
  end

  # Oban.Repo threads the instance's `prefix` into every query, so these hit
  # the same `oban_jobs` table the running Oban instance polls.
  defp oban_repo_one(query), do: Oban.Repo.one(Oban.config(), query)
  defp oban_repo_delete_all(query), do: Oban.Repo.delete_all(Oban.config(), query)
  defp oban_repo_aggregate(query, agg), do: Oban.Repo.aggregate(Oban.config(), query, agg)
end