Skip to content

Commit 4677a38

Browse files
feat: add canary auto-promotion and GraphQL/gRPC protocol UI
Wire CanaryAnalysis into the rollout tick loop so canary-strategy rollouts automatically promote through traffic steps or rollback based on error rate and latency thresholds. Add service type selector and protocol-specific config sections (GraphQL, gRPC) to service create/edit/show/index LiveViews.
1 parent 76fcc94 commit 4677a38

13 files changed

Lines changed: 1784 additions & 132 deletions

File tree

lib/sentinel_cp/rollouts.ex

Lines changed: 188 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -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

lib/sentinel_cp/rollouts/rollout.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ defmodule SentinelCp.Rollouts.Rollout do
4343
# Advanced deployment fields
4444
field :canary_analysis_config, :map
4545
field :canary_analysis_results, :map
46+
field :canary_step_index, :integer, default: 0
4647
field :deployment_slot, :string
4748
field :validation_period_seconds, :integer, default: 300
4849

@@ -75,6 +76,7 @@ defmodule SentinelCp.Rollouts.Rollout do
7576
:rollback_threshold,
7677
:custom_health_checks,
7778
:canary_analysis_config,
79+
:canary_step_index,
7880
:deployment_slot,
7981
:validation_period_seconds
8082
])
@@ -186,6 +188,14 @@ defmodule SentinelCp.Rollouts.Rollout do
186188
end)
187189
end
188190

191+
@doc """
192+
Changeset for updating canary analysis state.
193+
"""
194+
def canary_changeset(rollout, attrs) do
195+
rollout
196+
|> cast(attrs, [:canary_step_index, :canary_analysis_results])
197+
end
198+
189199
@doc """
190200
Changeset for updating approval state.
191201
"""

0 commit comments

Comments
 (0)