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:
Graham McIntire 2026-03-05 09:32:43 -06:00
parent ee4a7b8040
commit 31cccf97b9
No known key found for this signature in database
3 changed files with 76 additions and 9 deletions

View file

@ -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)
),

View file

@ -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)

View file

@ -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