perf: add organization_id to alerts for fast single-table queries
- Denormalize organization_id from device to alerts table - Add composite indexes on (organization_id, alert_type, resolved_at) - Optimize list_organization_active_alerts to use single-table query - Eliminates expensive 3-table joins (alerts → device → site) - Auto-populate organization_id when creating alerts - Backfill existing alerts in migration Fixes bug #14 from reliability audit.
This commit is contained in:
parent
ee4a7b8040
commit
31cccf97b9
3 changed files with 76 additions and 9 deletions
|
|
@ -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)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue