diff --git a/lib/towerops/agents.ex b/lib/towerops/agents.ex index d296bded..eda99219 100644 --- a/lib/towerops/agents.ex +++ b/lib/towerops/agents.ex @@ -9,6 +9,7 @@ defmodule Towerops.Agents do import Ecto.Query, warn: false alias Towerops.Agents.AgentAssignment + alias Towerops.Agents.AgentCache alias Towerops.Agents.AgentToken alias Towerops.Agents.ReleaseChecker alias Towerops.Devices.Device @@ -1022,6 +1023,17 @@ defmodule Towerops.Agents do """ @spec device_has_effective_agent?(Ecto.UUID.t()) :: boolean() def device_has_effective_agent?(device_id) do + AgentCache.device_has_effective_agent?(device_id) + end + + @doc """ + Queries the database directly to check if a device has an effective agent. + + This is the uncached version used by `AgentCache`. Callers should use + `device_has_effective_agent?/1` which reads from the ETS cache. + """ + @spec query_device_has_effective_agent?(Ecto.UUID.t()) :: boolean() + def query_device_has_effective_agent?(device_id) do query = from d in Device, left_join: aa in AgentAssignment, @@ -1043,7 +1055,7 @@ defmodule Towerops.Agents do result.has_device_assignment or result.has_site_token or result.has_org_token or - Towerops.Settings.get_global_default_cloud_poller() != nil + AgentCache.global_default_cloud_poller() != nil end end diff --git a/lib/towerops/agents/agent_cache.ex b/lib/towerops/agents/agent_cache.ex new file mode 100644 index 00000000..83267759 --- /dev/null +++ b/lib/towerops/agents/agent_cache.ex @@ -0,0 +1,92 @@ +defmodule Towerops.Agents.AgentCache do + @moduledoc """ + ETS-backed cache for device agent assignment lookups. + + Reduces database load by caching the result of `device_has_effective_agent?/1` + queries and the global default cloud poller setting. These values are checked + on every check/poll cycle for every device, generating thousands of redundant + queries per minute. + + Cache entries expire after a configurable TTL (default 2 minutes). Explicit + invalidation is available for immediate updates when assignments change. + """ + + use GenServer + + alias Towerops.Agents + alias Towerops.Settings + + @table :device_agent_cache + @ttl_ms to_timeout(second: 120) + + # Client API + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc """ + Checks if a device has any effective agent assigned, using cached result when available. + + Returns cached value if within TTL, otherwise queries the database and caches the result. + """ + @spec device_has_effective_agent?(Ecto.UUID.t()) :: boolean() + def device_has_effective_agent?(device_id) do + now = System.monotonic_time(:millisecond) + + case :ets.lookup(@table, {:device, device_id}) do + [{_, result, expires_at}] when now < expires_at -> + result + + _ -> + result = Agents.query_device_has_effective_agent?(device_id) + :ets.insert(@table, {{:device, device_id}, result, now + @ttl_ms}) + result + end + end + + @doc """ + Returns the cached global default cloud poller value. + + This is cached in ETS since it rarely changes and is read on every + `device_has_effective_agent?` call. + """ + @spec global_default_cloud_poller() :: String.t() | nil + def global_default_cloud_poller do + case :ets.lookup(@table, :global_default) do + [{:global_default, value}] -> value + [] -> refresh_global_default() + end + end + + @doc "Refreshes the cached global default cloud poller from the database." + @spec refresh_global_default() :: String.t() | nil + def refresh_global_default do + value = Settings.get_global_default_cloud_poller() + :ets.insert(@table, {:global_default, value}) + value + end + + @doc "Invalidates the cached agent check for a specific device." + @spec invalidate(Ecto.UUID.t()) :: true + def invalidate(device_id) do + :ets.delete(@table, {:device, device_id}) + end + + @doc "Invalidates all cached device agent checks." + @spec invalidate_all() :: true + def invalidate_all do + # Delete all device entries but keep the global default + :ets.match_delete(@table, {{:device, :_}, :_, :_}) + end + + # Server callbacks + + @impl GenServer + def init(_opts) do + table = :ets.new(@table, [:named_table, :public, :set, read_concurrency: true]) + # Pre-cache global default on startup + refresh_global_default() + {:ok, %{table: table}} + end +end diff --git a/lib/towerops/application.ex b/lib/towerops/application.ex index 4872eb5f..1cc433a9 100644 --- a/lib/towerops/application.ex +++ b/lib/towerops/application.ex @@ -110,6 +110,8 @@ defmodule Towerops.Application do {ErrorTrackerNotifier, []}, # Redis connection for brute force protection and rate limiting redis_spec(), + # Cache for device agent assignment lookups (reduces DB load from workers) + Towerops.Agents.AgentCache, # Rate limiting backend (ETS-based, per-node) {Towerops.RateLimit, [clean_period: to_timeout(minute: 1)]}, {DNSCluster, query: Application.get_env(:towerops, :dns_cluster_query) || :ignore}, diff --git a/lib/towerops/workers/check_worker.ex b/lib/towerops/workers/check_worker.ex index 89cbaec1..2ab46b0d 100644 --- a/lib/towerops/workers/check_worker.ex +++ b/lib/towerops/workers/check_worker.ex @@ -76,13 +76,9 @@ defmodule Towerops.Workers.CheckWorker do end end + # Agent check already done by should_continue_checking?, no need to re-check defp maybe_perform_check(check) do - if should_phoenix_execute?(check) do - perform_check(check) - else - Logger.debug("Skipping Phoenix execution for check #{check.name} - assigned to remote agent") - :ok - end + perform_check(check) end defp schedule_next_check_with_error_handling(check_id, interval_seconds) do diff --git a/lib/towerops/workers/device_monitor_worker.ex b/lib/towerops/workers/device_monitor_worker.ex index 62342e10..66282609 100644 --- a/lib/towerops/workers/device_monitor_worker.ex +++ b/lib/towerops/workers/device_monitor_worker.ex @@ -66,21 +66,9 @@ defmodule Towerops.Workers.DeviceMonitorWorker do !Agents.device_has_effective_agent?(device.id) end + # Agent check already done by should_continue_monitoring?, no need to re-check defp maybe_perform_check(device) do - cond do - Client.phoenix_snmp_disabled() -> - :ok - - not device.monitoring_enabled -> - :ok - - Agents.device_has_effective_agent?(device.id) -> - Logger.info("Skipping Phoenix monitoring for device #{device.name} - device has agent assigned") - :ok - - true -> - perform_check(device) - end + perform_check(device) end defp schedule_next_check_with_error_handling(device_id) do diff --git a/lib/towerops/workers/device_poller_worker.ex b/lib/towerops/workers/device_poller_worker.ex index 51f4384a..63ef7748 100644 --- a/lib/towerops/workers/device_poller_worker.ex +++ b/lib/towerops/workers/device_poller_worker.ex @@ -83,21 +83,9 @@ defmodule Towerops.Workers.DevicePollerWorker do !Agents.device_has_effective_agent?(device.id) end + # Agent check already done by should_continue_polling?, no need to re-check defp maybe_poll_device(device) do - cond do - Client.phoenix_snmp_disabled() -> - :ok - - not device.snmp_enabled -> - :ok - - Agents.device_has_effective_agent?(device.id) -> - Logger.info("Skipping Phoenix poll for device #{device.name} - device has agent assigned") - :ok - - true -> - poll_device(device) - end + poll_device(device) end defp schedule_next_poll_with_error_handling(device_id, _stale_device) do diff --git a/lib/towerops_web/live/user_settings_live.html.heex b/lib/towerops_web/live/user_settings_live.html.heex index 59ba4254..1019b31b 100644 --- a/lib/towerops_web/live/user_settings_live.html.heex +++ b/lib/towerops_web/live/user_settings_live.html.heex @@ -971,17 +971,21 @@ -
- {t("Recent login attempts to your account, including successful and failed attempts.")} -
++ {t( + "Recent login attempts to your account, including successful and failed attempts." + )} +
+| {t("IP Address")} | @@ -1059,7 +1063,7 @@{format_location(attempt)} | -+ | {attempt.ip_address} |
|---|