@@ -20,6 +20,7 @@ defmodule SentinelCp.Rollouts do
2020 HealthChecker
2121 }
2222
23+ alias SentinelCp.Rollouts.CanaryAnalysis
2324 alias SentinelCp . { Bundles , Events , Nodes , Orgs , Projects }
2425 # Events module replaces Notifications with backward-compatible API
2526 alias SentinelCp.Events , as: Notifications
@@ -540,12 +541,16 @@ defmodule SentinelCp.Rollouts do
540541 { :error , :no_target_nodes }
541542 else
542543 batches =
543- chunk_into_batches (
544- node_ids ,
545- rollout . strategy ,
546- rollout . batch_size ,
547- rollout . batch_percentage
548- )
544+ if rollout . strategy == "canary" do
545+ plan_canary_batches ( node_ids , rollout . canary_analysis_config )
546+ else
547+ chunk_into_batches (
548+ node_ids ,
549+ rollout . strategy ,
550+ rollout . batch_size ,
551+ rollout . batch_percentage
552+ )
553+ end
549554
550555 result =
551556 Repo . transaction ( fn ->
@@ -577,10 +582,19 @@ defmodule SentinelCp.Rollouts do
577582 |> Repo . insert! ( )
578583 end
579584
585+ # Set canary_step_index for canary rollouts
586+ canary_changes =
587+ if rollout . strategy == "canary" do
588+ % { canary_step_index: 0 }
589+ else
590+ % { }
591+ end
592+
580593 # Transition to running
581594 { :ok , updated } =
582595 rollout
583596 |> Rollout . state_changeset ( "running" )
597+ |> Ecto.Changeset . change ( canary_changes )
584598 |> Repo . update ( )
585599
586600 # Enqueue first tick
@@ -600,6 +614,20 @@ defmodule SentinelCp.Rollouts do
600614 end
601615 end
602616
617+ defp plan_canary_batches ( node_ids , config ) do
618+ steps = ( config || % { } ) [ "steps" ] || [ 5 , 25 , 50 , 100 ]
619+ first_pct = List . first ( steps ) || 5
620+ total = length ( node_ids )
621+ canary_size = max ( 1 , ceil ( total * first_pct / 100 ) )
622+ { canary_nodes , remaining_nodes } = Enum . split ( node_ids , canary_size )
623+
624+ if remaining_nodes == [ ] do
625+ [ canary_nodes ]
626+ else
627+ [ canary_nodes , remaining_nodes ]
628+ end
629+ end
630+
603631 @ doc """
604632 Core state machine driver. Called by the TickWorker on each tick.
605633 """
@@ -914,31 +942,167 @@ defmodule SentinelCp.Rollouts do
914942 defp check_step_verifying ( rollout , step ) do
915943 # Check health gates (only for available nodes when max_unavailable is set)
916944 if check_health_gates ( rollout , step , available_node_ids ( rollout , step ) ) do
917- # Step completed
918- { :ok , _step } =
919- step
920- |> RolloutStep . state_changeset ( "completed" )
921- |> Repo . update ( )
945+ if rollout . strategy == "canary" do
946+ check_canary_analysis ( rollout , step )
947+ else
948+ complete_step ( rollout , step )
949+ end
950+ else
951+ check_step_deadline ( rollout , step )
952+ end
953+ end
922954
923- now = DateTime . utc_now ( ) |> DateTime . truncate ( :second )
955+ defp complete_step ( rollout , step ) do
956+ { :ok , _step } =
957+ step
958+ |> RolloutStep . state_changeset ( "completed" )
959+ |> Repo . update ( )
924960
925- from ( nbs in NodeBundleStatus ,
926- where: nbs . rollout_id == ^ rollout . id and nbs . node_id in ^ step . node_ids
927- )
928- |> Repo . update_all (
929- set: [ state: "active" , activated_at: now , verified_at: now , last_report_at: now ]
930- )
961+ now = DateTime . utc_now ( ) |> DateTime . truncate ( :second )
931962
932- # Set expected_bundle_id for nodes that completed this step
933- SentinelCp.Nodes . set_expected_bundle_for_nodes ( step . node_ids , rollout . bundle_id )
963+ from ( nbs in NodeBundleStatus ,
964+ where: nbs . rollout_id == ^ rollout . id and nbs . node_id in ^ step . node_ids
965+ )
966+ |> Repo . update_all (
967+ set: [ state: "active" , activated_at: now , verified_at: now , last_report_at: now ]
968+ )
934969
935- broadcast_rollout_update ( rollout )
936- { :ok , :step_completed }
937- else
938- check_step_deadline ( rollout , step )
970+ # Set expected_bundle_id for nodes that completed this step
971+ SentinelCp.Nodes . set_expected_bundle_for_nodes ( step . node_ids , rollout . bundle_id )
972+
973+ broadcast_rollout_update ( rollout )
974+ { :ok , :step_completed }
975+ end
976+
977+ defp check_canary_analysis ( rollout , step ) do
978+ # Determine canary vs baseline node IDs
979+ rollout_with_steps =
980+ Repo . preload ( rollout , steps: from ( s in RolloutStep , order_by: s . step_index ) )
981+
982+ canary_node_ids =
983+ rollout_with_steps . steps
984+ |> Enum . filter ( & ( & 1 . state in ~w( completed running verifying) ) )
985+ |> Enum . flat_map ( & & 1 . node_ids )
986+
987+ baseline_node_ids =
988+ rollout_with_steps . steps
989+ |> Enum . filter ( & ( & 1 . state == "pending" ) )
990+ |> Enum . flat_map ( & & 1 . node_ids )
991+
992+ { decision , result } = CanaryAnalysis . analyze ( rollout , canary_node_ids , baseline_node_ids )
993+ store_canary_result ( rollout , result )
994+
995+ case decision do
996+ :promote ->
997+ # Complete current step
998+ complete_step ( rollout , step )
999+
1000+ if CanaryAnalysis . next_step? ( rollout . canary_analysis_config , rollout . canary_step_index ) do
1001+ # Increment canary step index and recalculate next batch
1002+ new_index = rollout . canary_step_index + 1
1003+
1004+ next_pct =
1005+ CanaryAnalysis . current_step_percentage ( rollout . canary_analysis_config , new_index )
1006+
1007+ { :ok , _updated } =
1008+ rollout
1009+ |> Rollout . canary_changeset ( % { canary_step_index: new_index } )
1010+ |> Repo . update ( )
1011+
1012+ # Redistribute remaining nodes based on new percentage
1013+ redistribute_canary_steps ( rollout , new_index , next_pct )
1014+
1015+ { :ok , :canary_promoted }
1016+ else
1017+ # Final step — rollout will complete via normal tick cycle
1018+ { :ok , :canary_final_promote }
1019+ end
1020+
1021+ :rollback ->
1022+ rollback_rollout ( rollout )
1023+ { :ok , :canary_rollback }
1024+
1025+ :extend ->
1026+ # Not enough data — wait for next tick
1027+ { :ok , :canary_extend }
9391028 end
9401029 end
9411030
1031+ defp redistribute_canary_steps ( rollout , _new_index , next_pct ) do
1032+ # Get remaining pending step node IDs
1033+ rollout_with_steps =
1034+ Repo . preload ( rollout , steps: from ( s in RolloutStep , order_by: s . step_index ) )
1035+
1036+ pending_steps = Enum . filter ( rollout_with_steps . steps , & ( & 1 . state == "pending" ) )
1037+ remaining_node_ids = Enum . flat_map ( pending_steps , & & 1 . node_ids )
1038+
1039+ if remaining_node_ids != [ ] do
1040+ total_original = length ( get_rollout_node_ids ( rollout . id ) )
1041+ next_batch_size = max ( 1 , ceil ( total_original * next_pct / 100 ) )
1042+ # Cap at the number of remaining nodes
1043+ next_batch_size = min ( next_batch_size , length ( remaining_node_ids ) )
1044+ { next_batch , rest } = Enum . split ( remaining_node_ids , next_batch_size )
1045+
1046+ # Delete existing pending steps and create new ones
1047+ for pending_step <- pending_steps do
1048+ Repo . delete ( pending_step )
1049+ end
1050+
1051+ max_step_index =
1052+ rollout_with_steps . steps
1053+ |> Enum . map ( & & 1 . step_index )
1054+ |> Enum . max ( fn -> - 1 end )
1055+
1056+ # Create next canary batch step
1057+ { :ok , _step } =
1058+ % RolloutStep { }
1059+ |> RolloutStep . create_changeset ( % {
1060+ rollout_id: rollout . id ,
1061+ step_index: max_step_index + 1 ,
1062+ node_ids: next_batch
1063+ } )
1064+ |> Repo . insert ( )
1065+
1066+ # Create remaining nodes step if any left
1067+ if rest != [ ] do
1068+ { :ok , _step } =
1069+ % RolloutStep { }
1070+ |> RolloutStep . create_changeset ( % {
1071+ rollout_id: rollout . id ,
1072+ step_index: max_step_index + 2 ,
1073+ node_ids: rest
1074+ } )
1075+ |> Repo . insert ( )
1076+ end
1077+ end
1078+ end
1079+
1080+ defp store_canary_result ( rollout , result ) do
1081+ existing = rollout . canary_analysis_results || % { "analyses" => [ ] }
1082+ analyses = ( existing [ "analyses" ] || [ ] ) ++ [ stringify_result ( result ) ]
1083+
1084+ { :ok , _ } =
1085+ rollout
1086+ |> Rollout . canary_changeset ( % { canary_analysis_results: % { "analyses" => analyses } } )
1087+ |> Repo . update ( )
1088+ end
1089+
1090+ defp stringify_result ( result ) do
1091+ result
1092+ |> Map . new ( fn { k , v } ->
1093+ key = to_string ( k )
1094+
1095+ val =
1096+ case v do
1097+ % DateTime { } -> DateTime . to_iso8601 ( v )
1098+ % { } = m -> Map . new ( m , fn { mk , mv } -> { to_string ( mk ) , mv } end )
1099+ other -> other
1100+ end
1101+
1102+ { key , val }
1103+ end )
1104+ end
1105+
9421106 defp check_health_gates ( rollout , _step , check_node_ids ) do
9431107 gates = rollout . health_gates || % { }
9441108
0 commit comments