Skip to content

Commit 72bc8bb

Browse files
committed
feat: Add optimized root devices GraphQL connection
Replaces the unpaginated manual relationship `devices` in `DeviceGroup` with a scalable root `devices` connection that translates the group selector into a native Ash.Expr database filter. Updates Channel calculations (updatable_devices, deployable_devices, download_capable_devices) to execute Postgres-level filters instead of loading devices into memory, resolving OOM risks on huge groups. Updates the frontend `DeviceGroup` component to query the root connection and consume the standard `DevicesTable` component. Signed-off-by: Davide Briani <davide.briani@secomind.com>
1 parent 540193f commit 72bc8bb

12 files changed

Lines changed: 479 additions & 531 deletions

File tree

backend/lib/edgehog/campaigns/channel/calculations/deployable_devices.ex

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#
22
# This file is part of Edgehog.
33
#
4-
# Copyright 2025 SECO Mind Srl
4+
# Copyright 2025-2026 SECO Mind Srl
55
#
66
# Licensed under the Apache License, Version 2.0 (the "License");
77
# you may not use this file except in compliance with the License.
@@ -28,10 +28,13 @@ defmodule Edgehog.Campaigns.Channel.Calculations.DeployableDevices do
2828
use Ash.Resource.Calculation
2929

3030
alias Ash.Resource.Calculation
31+
alias Edgehog.Selector
32+
33+
require Ash.Query
3134

3235
@impl Calculation
3336
def load(_query, _opts, _context) do
34-
[target_groups: [devices: :system_model]]
37+
[target_groups: [:selector]]
3538
end
3639

3740
@impl Calculation
@@ -43,22 +46,42 @@ defmodule Edgehog.Campaigns.Channel.Calculations.DeployableDevices do
4346
|> Ash.load!(:system_models)
4447
|> Map.get(:system_models, [])
4548

46-
Enum.map(deployment_channels, fn deployment_channel ->
47-
deployment_channel.target_groups
48-
|> Enum.flat_map(fn target_group ->
49-
Enum.filter(
50-
target_group.devices,
51-
&satisfies?(&1.system_model, system_model_requirements)
52-
)
53-
end)
54-
|> Enum.uniq_by(& &1.id)
55-
end)
49+
deployment_channels
50+
|> Ash.load!(target_groups: [:selector])
51+
|> Enum.map(&resolve_deployment_channel(&1, system_model_requirements, context))
52+
end
53+
54+
defp resolve_deployment_channel(deployment_channel, system_model_requirements, context) do
55+
combined_expr = Enum.reduce(deployment_channel.target_groups, nil, &combine_selectors/2)
56+
57+
if combined_expr do
58+
query =
59+
Edgehog.Devices.Device
60+
|> Ash.Query.filter(^combined_expr)
61+
|> Ash.Query.set_tenant(context.tenant)
62+
63+
query =
64+
if system_model_requirements != [] do
65+
system_model_ids = Enum.map(system_model_requirements, & &1.id)
66+
Ash.Query.filter(query, system_model_part_number.system_model_id in ^system_model_ids)
67+
else
68+
query
69+
end
70+
71+
Ash.read!(query)
72+
else
73+
[]
74+
end
5675
end
5776

58-
defp satisfies?(_system_model, [] = _system_model_requirements), do: true
59-
defp satisfies?(nil = _system_model, _system_model_requirements), do: false
77+
defp combine_selectors(group, acc) do
78+
case Selector.parse(group.selector) do
79+
{:ok, ast} ->
80+
expr = Selector.to_ash_expr(ast)
81+
if acc, do: Ash.Expr.expr(^expr or ^acc), else: expr
6082

61-
defp satisfies?(system_model, system_model_requirements) do
62-
Enum.any?(system_model_requirements, &(system_model.id == &1.id))
83+
_ ->
84+
acc
85+
end
6386
end
6487
end

backend/lib/edgehog/campaigns/channel/calculations/download_capable_devices.ex

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,43 @@ defmodule Edgehog.Campaigns.Channel.Calculations.DownloadCapableDevices do
2222
@moduledoc false
2323
use Ash.Resource.Calculation
2424

25+
alias Edgehog.Selector
26+
2527
require Ash.Query
2628

2729
@impl Ash.Resource.Calculation
28-
def calculate(channels, _opts, _context) do
30+
def load(_query, _opts, _context) do
31+
[target_groups: [:selector]]
32+
end
33+
34+
@impl Ash.Resource.Calculation
35+
def calculate(channels, _opts, context) do
2936
channels
30-
|> Ash.load!(target_groups: [:devices])
31-
|> Enum.map(fn channel ->
32-
channel.target_groups
33-
|> Enum.flat_map(& &1.devices)
34-
|> Enum.uniq_by(& &1.id)
35-
end)
37+
|> Ash.load!(target_groups: [:selector])
38+
|> Enum.map(&resolve_channel(&1, context))
39+
end
40+
41+
defp resolve_channel(channel, context) do
42+
combined_expr = Enum.reduce(channel.target_groups, nil, &combine_selectors/2)
43+
44+
if combined_expr do
45+
Edgehog.Devices.Device
46+
|> Ash.Query.filter(^combined_expr)
47+
|> Ash.Query.set_tenant(context.tenant)
48+
|> Ash.read!()
49+
else
50+
[]
51+
end
52+
end
53+
54+
defp combine_selectors(group, acc) do
55+
case Selector.parse(group.selector) do
56+
{:ok, ast} ->
57+
expr = Selector.to_ash_expr(ast)
58+
if acc, do: Ash.Expr.expr(^expr or ^acc), else: expr
59+
60+
_ ->
61+
acc
62+
end
3663
end
3764
end

backend/lib/edgehog/campaigns/channel/calculations/updatable_devices.ex

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#
22
# This file is part of Edgehog.
33
#
4-
# Copyright 2024 SECO Mind Srl
4+
# Copyright 2024-2026 SECO Mind Srl
55
#
66
# Licensed under the Apache License, Version 2.0 (the "License");
77
# you may not use this file except in compliance with the License.
@@ -22,8 +22,15 @@ defmodule Edgehog.Campaigns.Channel.Calculations.UpdatableDevices do
2222
@moduledoc false
2323
use Ash.Resource.Calculation
2424

25+
alias Edgehog.Selector
26+
2527
require Ash.Query
2628

29+
@impl Ash.Resource.Calculation
30+
def load(_query, _opts, _context) do
31+
[target_groups: [:selector]]
32+
end
33+
2734
@impl Ash.Resource.Calculation
2835
def calculate(channels, _opts, context) do
2936
%{arguments: %{base_image: base_image}} = context
@@ -33,16 +40,32 @@ defmodule Edgehog.Campaigns.Channel.Calculations.UpdatableDevices do
3340
system_model_id = base_image.base_image_collection.system_model_id
3441

3542
channels
36-
|> Ash.load!(target_groups: [devices: :system_model])
37-
|> Enum.map(fn channel ->
38-
channel.target_groups
39-
|> Enum.flat_map(fn target_group ->
40-
Enum.filter(
41-
target_group.devices,
42-
&(&1.system_model != nil && &1.system_model.id == system_model_id)
43-
)
44-
end)
45-
|> Enum.uniq_by(& &1.id)
46-
end)
43+
|> Ash.load!(target_groups: [:selector])
44+
|> Enum.map(&resolve_channel(&1, system_model_id, context))
45+
end
46+
47+
defp resolve_channel(channel, system_model_id, context) do
48+
combined_expr = Enum.reduce(channel.target_groups, nil, &combine_selectors/2)
49+
50+
if combined_expr do
51+
Edgehog.Devices.Device
52+
|> Ash.Query.filter(^combined_expr)
53+
|> Ash.Query.filter(system_model_part_number.system_model_id == ^system_model_id)
54+
|> Ash.Query.set_tenant(context.tenant)
55+
|> Ash.read!()
56+
else
57+
[]
58+
end
59+
end
60+
61+
defp combine_selectors(group, acc) do
62+
case Selector.parse(group.selector) do
63+
{:ok, ast} ->
64+
expr = Selector.to_ash_expr(ast)
65+
if acc, do: Ash.Expr.expr(^expr or ^acc), else: expr
66+
67+
_ ->
68+
acc
69+
end
4770
end
4871
end

backend/lib/edgehog/devices/device/device.ex

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
defmodule Edgehog.Devices.Device do
2222
@moduledoc false
2323
use Edgehog.MultitenantResource,
24+
primary_read_warning?: false,
2425
domain: Edgehog.Devices,
2526
extensions: [
2627
AshGraphql.Resource
@@ -75,7 +76,24 @@ defmodule Edgehog.Devices.Device do
7576
end
7677

7778
actions do
78-
defaults [:read, :destroy]
79+
defaults [:destroy]
80+
81+
read :read do
82+
primary? true
83+
pagination keyset?: true, offset?: true, required?: false
84+
85+
argument :matching_group_id, :id do
86+
description "Filters devices dynamically based on a DeviceGroup's selector"
87+
allow_nil? true
88+
end
89+
90+
argument :matching_selector, :string do
91+
description "Optional raw selector for dry-runs"
92+
allow_nil? true
93+
end
94+
95+
prepare Edgehog.Devices.Device.Preparations.FilterBySelector
96+
end
7997

8098
create :create do
8199
primary? true
@@ -692,20 +710,21 @@ defmodule Edgehog.Devices.Device do
692710
end
693711

694712
postgres do
695-
table "devices"
696-
repo Edgehog.Repo
713+
table("devices")
714+
repo(Edgehog.Repo)
697715

698716
references do
699-
reference :realm,
717+
reference(:realm,
700718
index?: true,
701719
on_delete: :nothing,
702720
match_with: [tenant_id: :tenant_id],
703721
match_type: :full
722+
)
704723

705724
# We don't generate a foreign key for the system model part number since we want the device
706725
# to be able to declare its part number even _before_ we add the relative system model to
707726
# Edgehog
708-
reference :system_model_part_number, ignore?: true
727+
reference(:system_model_part_number, ignore?: true)
709728
end
710729
end
711730
end
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#
2+
# This file is part of Edgehog.
3+
#
4+
# Copyright 2026 SECO Mind Srl
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
# SPDX-License-Identifier: Apache-2.0
19+
#
20+
21+
defmodule Edgehog.Devices.Device.Preparations.FilterBySelector do
22+
@moduledoc false
23+
use Ash.Resource.Preparation
24+
25+
alias Edgehog.Selector
26+
27+
require Ash.Query
28+
29+
@impl Ash.Resource.Preparation
30+
def prepare(query, _opts, context) do
31+
selector_string = get_selector(query, context)
32+
33+
if selector_string do
34+
{:ok, ast} = Selector.parse(selector_string)
35+
ash_expr = Selector.to_ash_expr(ast)
36+
Ash.Query.filter(query, ^ash_expr)
37+
else
38+
query
39+
end
40+
end
41+
42+
defp get_selector(query, context) do
43+
selector = Ash.Query.get_argument(query, :matching_selector)
44+
group_id = Ash.Query.get_argument(query, :matching_group_id)
45+
46+
cond do
47+
is_binary(selector) ->
48+
selector
49+
50+
group_id != nil ->
51+
group =
52+
Edgehog.Groups.DeviceGroup
53+
|> Ash.Query.filter(id == ^group_id)
54+
|> Ash.Query.set_tenant(context.tenant)
55+
|> Ash.read_first!()
56+
57+
if group, do: group.selector, else: nil
58+
59+
true ->
60+
nil
61+
end
62+
end
63+
end

0 commit comments

Comments
 (0)