diff --git a/lib/towerops/alerts.ex b/lib/towerops/alerts.ex index d4e70fe5..99db94d7 100644 --- a/lib/towerops/alerts.ex +++ b/lib/towerops/alerts.ex @@ -15,15 +15,29 @@ defmodule Towerops.Alerts do For device_down alerts, automatically computes and stores Gaiia impact analysis if the device belongs to an organization with a Gaiia integration. + + Automatically populates organization_id from the device for query performance. """ @spec create_alert(map()) :: {:ok, Alert.t()} | {:error, Ecto.Changeset.t()} def create_alert(attrs) do + attrs = enrich_with_organization_id(attrs) + with {:ok, alert} <- Repo.insert(Alert.changeset(%Alert{}, attrs)) do maybe_compute_gaiia_impact(alert) {:ok, alert} end end + # Populate organization_id from device for denormalized query performance + defp enrich_with_organization_id(%{device_id: device_id} = attrs) when not is_nil(device_id) do + case Towerops.Devices.get_device(device_id) do + nil -> attrs + device -> Map.put(attrs, :organization_id, device.organization_id) + end + end + + defp enrich_with_organization_id(attrs), do: attrs + defp maybe_compute_gaiia_impact(%Alert{alert_type: "device_down", device_id: device_id} = alert) when not is_nil(device_id) do device = Towerops.Devices.get_device(device_id) @@ -80,33 +94,33 @@ defmodule Towerops.Alerts do @doc """ Returns the list of active (unresolved) alerts for an organization. - Only returns equipment_down alerts, as equipment_up alerts are informational. + Only returns device_down alerts, as device_up alerts are informational. + + Uses denormalized organization_id for fast single-table query with composite index. """ @spec list_organization_active_alerts(String.t()) :: [Alert.t()] def list_organization_active_alerts(organization_id) do Repo.all( from(a in Alert, - join: e in assoc(a, :device), - join: s in assoc(e, :site), - where: s.organization_id == ^organization_id, + where: a.organization_id == ^organization_id, where: a.alert_type == "device_down", where: is_nil(a.resolved_at), order_by: [desc: a.triggered_at], - preload: [:acknowledged_by, device: {e, site: s}] + preload: [:acknowledged_by, device: [:site]] ) ) end @doc """ Returns the count of active (unresolved) alerts for an organization. + + Uses denormalized organization_id for fast single-table query with composite index. """ @spec count_active_alerts(String.t()) :: integer() def count_active_alerts(organization_id) do Repo.aggregate( from(a in Alert, - join: e in assoc(a, :device), - join: s in assoc(e, :site), - where: s.organization_id == ^organization_id, + where: a.organization_id == ^organization_id, where: a.alert_type == "device_down", where: is_nil(a.resolved_at) ), diff --git a/lib/towerops/alerts/alert.ex b/lib/towerops/alerts/alert.ex index ad62edc9..436dc5ec 100644 --- a/lib/towerops/alerts/alert.ex +++ b/lib/towerops/alerts/alert.ex @@ -13,6 +13,7 @@ defmodule Towerops.Alerts.Alert do alias Towerops.Accounts.User alias Towerops.Devices.Device alias Towerops.Monitoring.Check + alias Towerops.Organizations.Organization @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id @@ -36,6 +37,7 @@ defmodule Towerops.Alerts.Alert do belongs_to :device, Device belongs_to :check, Check belongs_to :acknowledged_by, User + belongs_to :organization, Organization timestamps(type: :utc_datetime) end @@ -59,6 +61,8 @@ defmodule Towerops.Alerts.Alert do check: NotLoaded.t() | Check.t() | nil, acknowledged_by_id: Ecto.UUID.t() | nil, acknowledged_by: NotLoaded.t() | User.t() | nil, + organization_id: Ecto.UUID.t() | nil, + organization: NotLoaded.t() | Organization.t() | nil, inserted_at: DateTime.t(), updated_at: DateTime.t() } @@ -80,7 +84,8 @@ defmodule Towerops.Alerts.Alert do :severity, :notification_sent, :notification_sent_at, - :output + :output, + :organization_id ]) |> validate_required([:alert_type, :triggered_at]) |> validate_inclusion(:severity, [1, 2]) @@ -88,6 +93,7 @@ defmodule Towerops.Alerts.Alert do |> foreign_key_constraint(:device_id) |> foreign_key_constraint(:check_id) |> foreign_key_constraint(:acknowledged_by_id) + |> foreign_key_constraint(:organization_id) end # Ensure either device_id or check_id is present (but not required for flexibility) diff --git a/priv/repo/migrations/20260305152930_add_organization_id_to_alerts.exs b/priv/repo/migrations/20260305152930_add_organization_id_to_alerts.exs new file mode 100644 index 00000000..2fb3517f --- /dev/null +++ b/priv/repo/migrations/20260305152930_add_organization_id_to_alerts.exs @@ -0,0 +1,47 @@ +defmodule Towerops.Repo.Migrations.AddOrganizationIdToAlerts do + use Ecto.Migration + + def up do + # Add organization_id to alerts for query performance + # Denormalized from device.organization_id to avoid expensive joins + alter table(:alerts) do + add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all) + end + + # Backfill organization_id for existing alerts from their devices + execute """ + UPDATE alerts + SET organization_id = devices.organization_id + FROM devices + WHERE alerts.device_id = devices.id + AND alerts.organization_id IS NULL + """ + + # Create composite index for fast organization alert queries + # Replaces expensive 3-table joins with single-table index scan + # Partial index only on unresolved alerts for efficiency + create_if_not_exists index(:alerts, [:organization_id, :alert_type, :resolved_at], + where: "resolved_at IS NULL", + name: :idx_alerts_org_type_unresolved + ) + + # Create general index for organization queries (includes resolved) + create_if_not_exists index(:alerts, [:organization_id, :triggered_at], + name: :idx_alerts_org_triggered + ) + end + + def down do + drop_if_exists index(:alerts, [:organization_id, :triggered_at], + name: :idx_alerts_org_triggered + ) + + drop_if_exists index(:alerts, [:organization_id, :alert_type, :resolved_at], + name: :idx_alerts_org_type_unresolved + ) + + alter table(:alerts) do + remove :organization_id + end + end +end