Skip to content

Commit 35a9aab

Browse files
feat: Add Pause and Resume functionality for campaigns (#1204)
* feat(backend): Add pause and resume mutations for campaigns - Pausing a campaign stops any new target execution and disables retries, while continuing to listen for and process device updates for already in-progress operations. - Resuming a campaign restores retry timers and continues the rollout from the current state (same counters, same slots), fetching new targets as capacity allows. - Add missing tests for deployment upgrade campaigns. Adds the ability to pause and resume campaigns from the Campaign Details page. When a campaign is paused, no new targets will be assigned, but in-progress targets will continue until completion. Closes #277 Signed-off-by: Omar <omar.brbutovic@secomind.com>
1 parent 09b494e commit 35a9aab

34 files changed

Lines changed: 2615 additions & 32 deletions

File tree

backend/lib/edgehog/campaigns/campaign/campaign.ex

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ defmodule Edgehog.Campaigns.Campaign do
2222
@moduledoc false
2323
use Edgehog.MultitenantResource,
2424
domain: Edgehog.Campaigns,
25-
extensions: [AshGraphql.Resource]
25+
extensions: [AshGraphql.Resource],
26+
notifiers: [Ash.Notifier.PubSub]
2627

2728
alias Edgehog.Campaigns.Campaign.Changes
2829
alias Edgehog.Campaigns.Campaign.Validations
@@ -44,7 +45,7 @@ defmodule Edgehog.Campaigns.Campaign do
4445
read :read_all_resumable do
4546
multitenancy :allow_global
4647
pagination keyset?: true
47-
filter expr(status in [:idle, :in_progress])
48+
filter expr(status in [:idle, :in_progress, :pausing])
4849
end
4950

5051
read :update_campaign do
@@ -121,6 +122,24 @@ defmodule Edgehog.Campaigns.Campaign do
121122
change set_attribute(:outcome, :success)
122123
end
123124

125+
update :mark_as_paused do
126+
change set_attribute(:status, :paused)
127+
end
128+
129+
update :pause do
130+
require_atomic? false
131+
132+
validate {Validations.ValidateStatus, operation: :pause}
133+
change set_attribute(:status, :pausing)
134+
end
135+
136+
update :resume do
137+
require_atomic? false
138+
139+
validate {Validations.ValidateStatus, operation: :resume}
140+
change Changes.StartExecution
141+
end
142+
124143
destroy :destroy do
125144
description "Deletes a Campaign"
126145
primary? true
@@ -206,6 +225,13 @@ defmodule Edgehog.Campaigns.Campaign do
206225
end
207226
end
208227

228+
pub_sub do
229+
prefix "campaigns"
230+
module EdgehogWeb.Endpoint
231+
232+
publish :pause, [[:id, "*"]]
233+
end
234+
209235
postgres do
210236
table "campaigns"
211237

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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.Campaigns.Campaign.Changes.StartExecution do
22+
@moduledoc false
23+
24+
use Ash.Resource.Change
25+
26+
alias Edgehog.Campaigns.ExecutorSupervisor
27+
28+
@impl Ash.Resource.Change
29+
def change(changeset, _opts, _context) do
30+
changeset
31+
|> Ash.Changeset.change_attribute(:status, :in_progress)
32+
|> Ash.Changeset.after_transaction(fn _changeset, result ->
33+
start_campaign_executor(result)
34+
end)
35+
end
36+
37+
defp start_campaign_executor({:ok, campaign} = _transaction_result) do
38+
_pid = ExecutorSupervisor.start_executor!(campaign)
39+
40+
{:ok, campaign}
41+
end
42+
43+
defp start_campaign_executor(transaction_result), do: transaction_result
44+
end
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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.Campaigns.Campaign.Validations.ValidateStatus do
22+
@moduledoc """
23+
Validates that a campaign is in the expected status for an operation.
24+
25+
## Options
26+
- `operation` - The operation being performed, used in error messages (required)
27+
"""
28+
use Ash.Resource.Validation
29+
30+
alias Ash.Error.Changes
31+
32+
@impl Ash.Resource.Validation
33+
def validate(changeset, opts, _context) do
34+
operation = Keyword.fetch!(opts, :operation)
35+
status = Ash.Changeset.get_attribute(changeset, :status)
36+
37+
validate_transition(status, operation)
38+
end
39+
40+
defp validate_transition(status, :pause) do
41+
case status do
42+
:in_progress ->
43+
:ok
44+
45+
_other ->
46+
{:error,
47+
Changes.InvalidAttribute.exception(
48+
field: :status,
49+
message: "Cannot pause campaign. Campaign must be in progress (current status: #{status})"
50+
)}
51+
end
52+
end
53+
54+
defp validate_transition(status, :resume) do
55+
case status do
56+
:paused ->
57+
:ok
58+
59+
_other ->
60+
{:error,
61+
Changes.InvalidAttribute.exception(
62+
field: :status,
63+
message: "Cannot resume campaign. Campaign must be paused (current status: #{status})"
64+
)}
65+
end
66+
end
67+
end

backend/lib/edgehog/campaigns/campaign_mechanism/core.ex

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ defprotocol Edgehog.Campaigns.CampaignMechanism.Core do
3232
def mark_campaign_in_progress!(mechanism, campaign, now \\ DateTime.utc_now())
3333
def mark_campaign_as_failed!(mechanism, campaign, now \\ DateTime.utc_now())
3434
def mark_campaign_as_successful!(mechanism, campaign, now \\ DateTime.utc_now())
35+
def mark_campaign_as_paused!(mechanism, campaign)
3536
def get_campaign_status(mechanism, campaign)
3637
def get_target_count(mechanism, tenant_id, campaign_id)
3738
def get_failed_target_count(mechanism, tenant_id, campaign_id)
@@ -139,19 +140,33 @@ defimpl Edgehog.Campaigns.CampaignMechanism.Core, for: Any do
139140
Campaigns.mark_campaign_successful!(campaign, %{completion_timestamp: now})
140141
end
141142

143+
@doc """
144+
Marks a campaign as paused.
145+
146+
## Parameters
147+
- mechanism: The campaign mechanism (unused in default implementation).
148+
- campaign: The campaign struct.
149+
150+
## Returns
151+
- The updated campaign struct marked as paused.
152+
"""
153+
def mark_campaign_as_paused!(_mechanism, campaign) do
154+
Campaigns.mark_campaign_paused!(campaign)
155+
end
156+
142157
# Campaign Data
143158

144159
@doc """
145160
Return the persisted campaign status.
146161
147-
Expected values are `:idle`, `:in_progress` or `:finished`.
162+
Expected values are `:idle`, `:in_progress`, `:pausing`, `:paused` or `:finished`.
148163
149164
## Parameters
150165
- mechanism: The campaign mechanism (unused in default implementation).
151166
- campaign: The campaign struct.
152167
153168
## Returns
154-
- The campaign status atom (`:idle`, `:in_progress`, or `:finished`).
169+
- The campaign status atom.
155170
"""
156171
def get_campaign_status(_mechanism, campaign), do: campaign.status
157172

backend/lib/edgehog/campaigns/campaign_mechanism/deployment_delete/core.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,9 @@ defimpl Edgehog.Campaigns.CampaignMechanism.Core,
288288
defdelegate mark_campaign_as_successful!(mechanism, campaign, now \\ DateTime.utc_now()),
289289
to: Any
290290

291+
defdelegate mark_campaign_as_paused!(mechanism, campaign),
292+
to: Any
293+
291294
defdelegate get_campaign_status(mechanism, campaign),
292295
to: Any
293296

backend/lib/edgehog/campaigns/campaign_mechanism/deployment_delete/executor.ex

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,23 @@ defmodule Edgehog.Campaigns.CampaignMechanism.DeploymentDelete.Executor do
3232
{:next_state, :initialization, data, internal_event(:init_data)}
3333
end
3434

35+
# Valid states from which pausing is allowed
36+
@pauseable_states [
37+
:execution,
38+
:wait_for_available_slot,
39+
:wait_for_target,
40+
:wait_for_campaign_completion
41+
]
42+
3543
# Common event handling
3644

3745
# Note that external (e.g. :info) and timeout events are always handled after the internal
3846
# events enqueued with the :next_event action. This means that we can be sure an :info event
3947
# or a timeout won't be handled, e.g., between a rollout and the handling of its error
4048
@impl LazyBatch
41-
def handle_info(%Phoenix.Socket.Broadcast{} = notification, _state, data) do
49+
def handle_info(%Phoenix.Socket.Broadcast{} = notification, state, data) do
4250
case notification.payload.action.type do
43-
:update -> handle_update(notification, data)
51+
:update -> handle_update(notification, state, data)
4452
:destroy -> handle_destroy(notification, data)
4553
_ -> :keep_state_and_data
4654
end
@@ -51,9 +59,10 @@ defmodule Edgehog.Campaigns.CampaignMechanism.DeploymentDelete.Executor do
5159
:keep_state_and_data
5260
end
5361

54-
defp handle_update(notification, data) do
62+
defp handle_update(notification, state, data) do
5563
case notification.payload.action.name do
5664
:mark_as_timed_out -> handle_mark_as_timed_out(notification, data)
65+
:pause -> handle_mark_as_paused(state, data)
5766
_ -> :keep_state_and_data
5867
end
5968
end
@@ -92,4 +101,13 @@ defmodule Edgehog.Campaigns.CampaignMechanism.DeploymentDelete.Executor do
92101

93102
{:keep_state_and_data, actions}
94103
end
104+
105+
defp handle_mark_as_paused(state, data) when state in @pauseable_states do
106+
{:next_state, :wait_for_campaign_paused, data, []}
107+
end
108+
109+
defp handle_mark_as_paused(_state, _data) do
110+
# Ignore pause requests in non-pauseable states (terminal states, already pausing, etc.)
111+
:keep_state_and_data
112+
end
95113
end

backend/lib/edgehog/campaigns/campaign_mechanism/deployment_deploy/core.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,9 @@ defimpl Edgehog.Campaigns.CampaignMechanism.Core,
281281
defdelegate mark_campaign_as_successful!(mechanism, campaign, now \\ DateTime.utc_now()),
282282
to: Any
283283

284+
defdelegate mark_campaign_as_paused!(mechanism, campaign),
285+
to: Any
286+
284287
defdelegate get_campaign_status(mechanism, campaign),
285288
to: Any
286289

backend/lib/edgehog/campaigns/campaign_mechanism/deployment_deploy/executor.ex

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,23 @@ defmodule Edgehog.Campaigns.CampaignMechanism.DeploymentDeploy.Executor do
3232
{:next_state, :initialization, data, internal_event(:init_data)}
3333
end
3434

35-
# Common event handling
36-
3735
# Note that external (e.g. :info) and timeout events are always handled after the internal
3836
# events enqueued with the :next_event action. This means that we can be sure an :info event
3937
# or a timeout won't be handled, e.g., between a rollout and the handling of its error
38+
# Valid states from which pausing is allowed
39+
@pauseable_states [
40+
:execution,
41+
:wait_for_available_slot,
42+
:wait_for_target,
43+
:wait_for_campaign_completion
44+
]
45+
46+
# Common event handling
47+
4048
@impl LazyBatch
41-
def handle_info(%Phoenix.Socket.Broadcast{} = notification, _state, data) do
49+
def handle_info(%Phoenix.Socket.Broadcast{} = notification, state, data) do
4250
case notification.payload.action.type do
43-
:update -> handle_update(notification, data)
51+
:update -> handle_update(notification, state, data)
4452
_ -> :keep_state_and_data
4553
end
4654
end
@@ -50,10 +58,11 @@ defmodule Edgehog.Campaigns.CampaignMechanism.DeploymentDeploy.Executor do
5058
:keep_state_and_data
5159
end
5260

53-
defp handle_update(notification, data) do
61+
defp handle_update(notification, state, data) do
5462
case notification.payload.action.name do
5563
:maybe_run_ready_actions -> handle_maybe_run_ready_actions(notification, data)
5664
:mark_as_timed_out -> handle_mark_as_timed_out(notification, data)
65+
:pause -> handle_mark_as_paused(state, data)
5766
_ -> :keep_state_and_data
5867
end
5968
end
@@ -94,4 +103,13 @@ defmodule Edgehog.Campaigns.CampaignMechanism.DeploymentDeploy.Executor do
94103

95104
{:keep_state_and_data, actions}
96105
end
106+
107+
defp handle_mark_as_paused(state, data) when state in @pauseable_states do
108+
{:next_state, :wait_for_campaign_paused, data, []}
109+
end
110+
111+
defp handle_mark_as_paused(_state, _data) do
112+
# Ignore pause requests in non-pauseable states (terminal states, already pausing, etc.)
113+
:keep_state_and_data
114+
end
97115
end

backend/lib/edgehog/campaigns/campaign_mechanism/deployment_start/core.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,9 @@ defimpl Edgehog.Campaigns.CampaignMechanism.Core,
289289
defdelegate mark_campaign_as_successful!(mechanism, campaign, now \\ DateTime.utc_now()),
290290
to: Any
291291

292+
defdelegate mark_campaign_as_paused!(mechanism, campaign),
293+
to: Any
294+
292295
defdelegate get_campaign_status(mechanism, campaign),
293296
to: Any
294297

0 commit comments

Comments
 (0)