Skip to content

Commit b92ace8

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 b92ace8

12 files changed

Lines changed: 464 additions & 528 deletions

File tree

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

Lines changed: 36 additions & 17 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,12 @@ defmodule Edgehog.Campaigns.Channel.Calculations.DeployableDevices do
2828
use Ash.Resource.Calculation
2929

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

3234
@impl Calculation
3335
def load(_query, _opts, _context) do
34-
[target_groups: [devices: :system_model]]
36+
[target_groups: [:selector]]
3537
end
3638

3739
@impl Calculation
@@ -43,22 +45,39 @@ defmodule Edgehog.Campaigns.Channel.Calculations.DeployableDevices do
4345
|> Ash.load!(:system_models)
4446
|> Map.get(:system_models, [])
4547

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)
56-
end
48+
deployment_channels
49+
|> Ash.load!(target_groups: [:selector])
50+
|> Enum.map(fn deployment_channel ->
51+
combined_expr =
52+
Enum.reduce(deployment_channel.target_groups, nil, fn group, acc ->
53+
case Selector.parse(group.selector) do
54+
{:ok, ast} ->
55+
expr = Selector.to_ash_expr(ast)
56+
if acc, do: Ash.Expr.expr(^expr or ^acc), else: expr
57+
58+
_ ->
59+
acc
60+
end
61+
end)
5762

58-
defp satisfies?(_system_model, [] = _system_model_requirements), do: true
59-
defp satisfies?(nil = _system_model, _system_model_requirements), do: false
63+
if combined_expr do
64+
query =
65+
Edgehog.Devices.Device
66+
|> Ash.Query.filter(^combined_expr)
67+
|> Ash.Query.set_tenant(context.tenant)
6068

61-
defp satisfies?(system_model, system_model_requirements) do
62-
Enum.any?(system_model_requirements, &(system_model.id == &1.id))
69+
query =
70+
if system_model_requirements != [] do
71+
system_model_ids = Enum.map(system_model_requirements, & &1.id)
72+
Ash.Query.filter(query, system_model_part_number.system_model_id in ^system_model_ids)
73+
else
74+
query
75+
end
76+
77+
Ash.read!(query)
78+
else
79+
[]
80+
end
81+
end)
6382
end
6483
end

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

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,38 @@ defmodule Edgehog.Campaigns.Channel.Calculations.DownloadCapableDevices do
2323
use Ash.Resource.Calculation
2424

2525
require Ash.Query
26+
alias Edgehog.Selector
2627

2728
@impl Ash.Resource.Calculation
28-
def calculate(channels, _opts, _context) do
29+
def load(_query, _opts, _context) do
30+
[target_groups: [:selector]]
31+
end
32+
33+
@impl Ash.Resource.Calculation
34+
def calculate(channels, _opts, context) do
2935
channels
30-
|> Ash.load!(target_groups: [:devices])
36+
|> Ash.load!(target_groups: [:selector])
3137
|> Enum.map(fn channel ->
32-
channel.target_groups
33-
|> Enum.flat_map(& &1.devices)
34-
|> Enum.uniq_by(& &1.id)
38+
combined_expr =
39+
Enum.reduce(channel.target_groups, nil, fn group, acc ->
40+
case Selector.parse(group.selector) do
41+
{:ok, ast} ->
42+
expr = Selector.to_ash_expr(ast)
43+
if acc, do: Ash.Expr.expr(^expr or ^acc), else: expr
44+
45+
_ ->
46+
acc
47+
end
48+
end)
49+
50+
if combined_expr do
51+
Edgehog.Devices.Device
52+
|> Ash.Query.filter(^combined_expr)
53+
|> Ash.Query.set_tenant(context.tenant)
54+
|> Ash.read!()
55+
else
56+
[]
57+
end
3558
end)
3659
end
3760
end

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

Lines changed: 29 additions & 10 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.
@@ -23,6 +23,12 @@ defmodule Edgehog.Campaigns.Channel.Calculations.UpdatableDevices do
2323
use Ash.Resource.Calculation
2424

2525
require Ash.Query
26+
alias Edgehog.Selector
27+
28+
@impl Ash.Resource.Calculation
29+
def load(_query, _opts, _context) do
30+
[target_groups: [:selector]]
31+
end
2632

2733
@impl Ash.Resource.Calculation
2834
def calculate(channels, _opts, context) do
@@ -33,16 +39,29 @@ defmodule Edgehog.Campaigns.Channel.Calculations.UpdatableDevices do
3339
system_model_id = base_image.base_image_collection.system_model_id
3440

3541
channels
36-
|> Ash.load!(target_groups: [devices: :system_model])
42+
|> Ash.load!(target_groups: [:selector])
3743
|> 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)
44+
combined_expr =
45+
Enum.reduce(channel.target_groups, nil, fn group, acc ->
46+
case Selector.parse(group.selector) do
47+
{:ok, ast} ->
48+
expr = Selector.to_ash_expr(ast)
49+
if acc, do: Ash.Expr.expr(^expr or ^acc), else: expr
50+
51+
_ ->
52+
acc
53+
end
54+
end)
55+
56+
if combined_expr do
57+
Edgehog.Devices.Device
58+
|> Ash.Query.filter(^combined_expr)
59+
|> Ash.Query.filter(system_model_part_number.system_model_id == ^system_model_id)
60+
|> Ash.Query.set_tenant(context.tenant)
61+
|> Ash.read!()
62+
else
63+
[]
64+
end
4665
end)
4766
end
4867
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)