Deployment and lifecycle management for Nix
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: cancel stale deployments when same sub deploys

When a new deployment is created for a subscription, any existing stale
deployments for that subscription are transitioned to a new :canceled state.
Also updates reconcile_deployments_on_connect to use :canceled instead of
:stale for explicitly superseded deployments.

sow-117

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+98 -7
+26 -2
apps/sower/lib/sower/orchestration/deployment.ex
··· 50 50 field :result, Ecto.Enum, values: [:success, :failure, :partial] 51 51 52 52 field :state, Ecto.Enum, 53 - values: [:created, :dispatched, :acknowledged, :completed, :stale], 53 + values: [:created, :dispatched, :acknowledged, :completed, :stale, :canceled], 54 54 default: :created 55 55 56 56 field :last_dispatched_at, :utc_datetime_usec ··· 299 299 end) 300 300 301 301 Enum.each(to_cancel, fn deployment -> 302 - update_deployment(deployment, %{state: :stale, result: :failure}) 302 + update_deployment(deployment, %{state: :canceled, result: :failure}) 303 303 end) 304 304 305 305 # 3. Replay valid unresolved deployments ··· 513 513 end 514 514 end 515 515 516 + # Cancel stale deployments for subscriptions 517 + 518 + def cancel_stale_for_subscriptions(subscription_ids) when is_list(subscription_ids) do 519 + stale_deployments = 520 + from(d in __MODULE__, 521 + join: sd in "subscriptions_deployments", 522 + on: sd.deployment_id == d.id, 523 + where: sd.subscription_id in ^subscription_ids, 524 + where: d.state == :stale, 525 + distinct: true 526 + ) 527 + |> Repo.all() 528 + 529 + Enum.reduce(stale_deployments, 0, fn deployment, acc -> 530 + case update_deployment(deployment, %{state: :canceled}) do 531 + {:ok, _} -> acc + 1 532 + _ -> acc 533 + end 534 + end) 535 + end 536 + 516 537 # Private helpers 517 538 518 539 defp validate_request_subscriptions(sids) when is_list(sids) and length(sids) > 0 do ··· 623 644 subscriptions: subscriptions 624 645 }) do 625 646 {:ok, deploy} -> 647 + subscription_ids = Enum.map(subscriptions, & &1.id) 648 + cancel_stale_for_subscriptions(subscription_ids) 649 + 626 650 Logger.info( 627 651 msg: "Deployment record created successfully", 628 652 request_id: request_id,
+7
apps/sower/lib/sower_web/components/sower_components.ex
··· 342 342 </span> 343 343 <span class={@compact && "sr-only"}>Stale</span> 344 344 </span> 345 + <% :canceled -> %> 346 + <span class="inline-flex items-center gap-1.5 text-sm text-zinc-400 dark:text-zinc-500"> 347 + <span class="relative flex h-2.5 w-2.5" role="img" aria-label="Canceled"> 348 + <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-zinc-400" /> 349 + </span> 350 + <span class={@compact && "sr-only"}>Canceled</span> 351 + </span> 345 352 <% end %> 346 353 """ 347 354 end
+2 -2
apps/sower/lib/sower_web/live/deployment_live/index.ex
··· 98 98 end 99 99 100 100 defp retryable?(deployment) do 101 - deployment.state in [:completed, :stale] 101 + deployment.state in [:completed, :stale, :canceled] 102 102 end 103 103 104 104 defp maybe_add_filter(filters, _field, _op, nil), do: filters ··· 124 124 defp filter_value(_meta, _field), do: nil 125 125 126 126 defp state_options do 127 - ["created", "dispatched", "acknowledged", "completed", "stale"] 127 + ["created", "dispatched", "acknowledged", "completed", "stale", "canceled"] 128 128 end 129 129 130 130 defp result_options do
+1 -1
apps/sower/lib/sower_web/live/deployment_live/show.ex
··· 226 226 end 227 227 228 228 defp retryable?(deployment) do 229 - deployment.state in [:completed, :stale] 229 + deployment.state in [:completed, :stale, :canceled] 230 230 end 231 231 232 232 defp initialize_socket(socket) do
+1 -1
apps/sower/lib/sower_web/live/garden_live/show.html.heex
··· 131 131 </:col> 132 132 <:action :let={deployment}> 133 133 <.button 134 - :if={deployment.state in [:completed, :stale]} 134 + :if={deployment.state in [:completed, :stale, :canceled]} 135 135 variant={:secondary} 136 136 type="button" 137 137 phx-click="retry_deployment"
+60
apps/sower/test/sower/orchestration/deployment_sequence_test.exs
··· 236 236 end 237 237 end 238 238 239 + describe "cancel stale on new deployment" do 240 + test "stale deployment gets canceled when same sub deploys", %{ 241 + org: org, 242 + garden: garden, 243 + seed: seed, 244 + subscription: subscription, 245 + deployment: deployment 246 + } do 247 + # Mark the existing deployment as stale 248 + {:ok, stale} = Deployment.update_deployment(deployment, %{state: :stale, result: :failure}) 249 + assert stale.state == :stale 250 + 251 + # Cancel stale deployments for the same subscription 252 + canceled_count = Deployment.cancel_stale_for_subscriptions([subscription.id]) 253 + 254 + # The stale deployment should now be canceled 255 + updated = Deployment.get_deployment!(deployment.id) 256 + assert updated.state == :canceled 257 + assert canceled_count == 1 258 + end 259 + 260 + test "non-stale deployments are not canceled", %{ 261 + subscription: subscription, 262 + deployment: deployment 263 + } do 264 + # deployment is in :dispatched state from setup 265 + assert deployment.state == :dispatched 266 + 267 + canceled_count = Deployment.cancel_stale_for_subscriptions([subscription.id]) 268 + 269 + updated = Deployment.get_deployment!(deployment.id) 270 + assert updated.state == :dispatched 271 + assert canceled_count == 0 272 + end 273 + 274 + test "stale deployments for different subs are not canceled", %{ 275 + garden: garden, 276 + deployment: deployment 277 + } do 278 + # Mark the existing deployment as stale 279 + {:ok, _stale} = Deployment.update_deployment(deployment, %{state: :stale, result: :failure}) 280 + 281 + # Create a different subscription 282 + other_sub = 283 + subscription_fixture(%{ 284 + garden_id: garden.id, 285 + seed_name: "other-seed", 286 + seed_type: "nixos" 287 + }) 288 + 289 + # Cancel stale for the other sub only 290 + canceled_count = Deployment.cancel_stale_for_subscriptions([other_sub.id]) 291 + 292 + # Original stale deployment should remain stale 293 + updated = Deployment.get_deployment!(deployment.id) 294 + assert updated.state == :stale 295 + assert canceled_count == 0 296 + end 297 + end 298 + 239 299 defp assert_seed_state(deployment, seed, expected_state) do 240 300 sd = fetch_seed_deployment(deployment, seed) 241 301 assert sd.state == expected_state
+1 -1
apps/sower/test/sower/orchestration_test.exs
··· 1077 1077 assert hd(overdue).sid == subscription.sid 1078 1078 1079 1079 refreshed = Orchestration.get_deployment_sid!(unresolved.sid) 1080 - assert refreshed.state == :stale 1080 + assert refreshed.state == :canceled 1081 1081 assert refreshed.result == :failure 1082 1082 end 1083 1083 end