Skip to content

Commit 3e04178

Browse files
roachtest: smarter cluster creation retries (#170222)
roachtest: smarter cluster creation retries
2 parents c46f1ee + 668287c commit 3e04178

19 files changed

Lines changed: 961 additions & 72 deletions

pkg/cmd/roachtest/cluster.go

Lines changed: 155 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,150 @@ func createFlagsOverride(opts *vm.CreateOpts) {
981981
}
982982
}
983983

984+
// createRetryPlanner owns safe mutations between roachprod create attempts.
985+
// The outer create loop still retries all create failures; this planner only
986+
// adds targeted attempt changes when a structured error makes that safe. Today
987+
// it handles zonal capacity errors by changing create zones. Future policies
988+
// can add other mutations here, such as falling back from spot to on-demand.
989+
type createRetryPlanner struct {
990+
clusterSpec spec.ClusterSpec
991+
params spec.RoachprodClusterConfig
992+
arch vm.CPUArch
993+
994+
// explicitZones records whether zones were set by roachtest config or the
995+
// test spec. These zones take precedence over provider defaults and provider
996+
// capacity error suggestions.
997+
explicitZones bool
998+
// retryCandidates is the provider's default non-geo zone pool. It is used as
999+
// a fallback after zonal capacity failures when no untried provider-suggested
1000+
// zone is available.
1001+
retryCandidates []string
1002+
// attemptedZones tracks zones selected for a create attempt or reported as
1003+
// failed by the provider. It prevents retrying a zone just because a provider
1004+
// hint or fallback list points back to it.
1005+
attemptedZones map[string]struct{}
1006+
// nextAttemptZones is a one-shot zone override selected after a capacity
1007+
// failure for the next create attempt.
1008+
nextAttemptZones []string
1009+
}
1010+
1011+
func newCreateRetryPlanner(
1012+
clusterSpec spec.ClusterSpec, params spec.RoachprodClusterConfig, arch vm.CPUArch,
1013+
) *createRetryPlanner {
1014+
explicitZones := clusterSpec.UsesExplicitZones(params)
1015+
var retryCandidates []string
1016+
if !clusterSpec.Geo && !explicitZones {
1017+
retryCandidates = spec.DefaultRetryZoneCandidates(params.Cloud, arch)
1018+
}
1019+
return &createRetryPlanner{
1020+
clusterSpec: clusterSpec,
1021+
params: params,
1022+
arch: arch,
1023+
explicitZones: explicitZones,
1024+
retryCandidates: retryCandidates,
1025+
attemptedZones: make(map[string]struct{}),
1026+
}
1027+
}
1028+
1029+
// zonesForAttempt returns the zones for the next roachprod create attempt. It
1030+
// consumes a pending retry override when one exists, otherwise it falls back to
1031+
// the test's ordinary roachprod create zones.
1032+
func (p *createRetryPlanner) zonesForAttempt() []string {
1033+
var zones []string
1034+
if len(p.nextAttemptZones) > 0 {
1035+
zones = append([]string(nil), p.nextAttemptZones...)
1036+
p.nextAttemptZones = nil
1037+
} else {
1038+
zones = p.clusterSpec.RoachprodCreateZones(p.params, p.arch)
1039+
}
1040+
for _, zone := range zones {
1041+
if zone != "" {
1042+
p.attemptedZones[zone] = struct{}{}
1043+
}
1044+
}
1045+
return zones
1046+
}
1047+
1048+
// recordFailure is called after a roachprod create attempt fails and decides
1049+
// whether the next attempt should change any create inputs. It leaves the
1050+
// ordinary retry loop behavior unchanged for failures it does not recognize or
1051+
// cannot safely plan around. For the current zone retry policy, explicit
1052+
// test/config zones and geo-distributed clusters are left alone because
1053+
// changing their zones would override user intent or require more
1054+
// topology-aware retry planning.
1055+
func (p *createRetryPlanner) recordFailure(err error, l *logger.Logger) {
1056+
var capacityErr *vm.CreateCapacityError
1057+
if !errors.As(err, &capacityErr) {
1058+
return
1059+
}
1060+
if capacityErr.Provider != "" && capacityErr.Provider != p.params.Cloud.String() {
1061+
return
1062+
}
1063+
if capacityErr.CapacityClass != vm.CreateCapacityClassZone {
1064+
return
1065+
}
1066+
if p.explicitZones {
1067+
l.Printf("provider reported capacity error, but zones are explicit; keeping requested zones")
1068+
return
1069+
}
1070+
if p.clusterSpec.Geo {
1071+
l.Printf("provider reported capacity error, but geo-distributed zone retry overrides are not supported")
1072+
return
1073+
}
1074+
1075+
retryZones, source := p.selectNextZones(capacityErr)
1076+
if len(retryZones) == 0 {
1077+
return
1078+
}
1079+
p.nextAttemptZones = retryZones
1080+
machineType := ""
1081+
if capacityErr.MachineType != "" {
1082+
machineType = fmt.Sprintf(" for machine type %s", capacityErr.MachineType)
1083+
}
1084+
failedZones := strings.Join(capacityErr.FailedZones, ",")
1085+
if failedZones == "" {
1086+
failedZones = "unknown"
1087+
}
1088+
l.Printf("provider reported capacity error%s in zone(s) [%s]; retrying next cluster creation in zone(s) [%s] from %s",
1089+
machineType,
1090+
failedZones,
1091+
strings.Join(retryZones, ","),
1092+
source,
1093+
)
1094+
}
1095+
1096+
// selectNextZones chooses the zone override for the next create attempt after a
1097+
// zonal capacity failure. Provider-suggested zones are preferred when they have
1098+
// not already been tried; otherwise it falls back to the first untried default
1099+
// retry candidate. The second return value describes the source of the selected
1100+
// zones for logging.
1101+
func (p *createRetryPlanner) selectNextZones(
1102+
capacityErr *vm.CreateCapacityError,
1103+
) ([]string, string) {
1104+
for _, zone := range capacityErr.FailedZones {
1105+
if zone != "" {
1106+
p.attemptedZones[zone] = struct{}{}
1107+
}
1108+
}
1109+
for _, zone := range capacityErr.SuggestedZones {
1110+
if zone == "" {
1111+
continue
1112+
}
1113+
if _, ok := p.attemptedZones[zone]; ok {
1114+
continue
1115+
}
1116+
return []string{zone}, "provider hint"
1117+
}
1118+
1119+
for _, zone := range p.retryCandidates {
1120+
if _, ok := p.attemptedZones[zone]; ok {
1121+
continue
1122+
}
1123+
return []string{zone}, "untried default zones"
1124+
}
1125+
return nil, ""
1126+
}
1127+
9841128
// clusterMock creates a cluster to be used for (self) testing.
9851129
func (f *clusterFactory) clusterMock(cfg clusterConfig) *roachprodCluster {
9861130
return &roachprodCluster{
@@ -1075,6 +1219,7 @@ func (f *clusterFactory) newRoachprodCluster(
10751219
// name. To keep things simple, disable retries in that case.
10761220
maxAttempts = 1
10771221
}
1222+
retryPlanner := newCreateRetryPlanner(cfg.spec, params, selectedArch)
10781223
// loop assumes maxAttempts is atleast (1).
10791224
for i := 1; ; i++ {
10801225
// NB: this intentionally avoids re-using the name across iterations in
@@ -1083,9 +1228,13 @@ func (f *clusterFactory) newRoachprodCluster(
10831228
// https://github.com/cockroachdb/cockroach/issues/67906#issuecomment-887477675
10841229
genName := f.genName(cfg)
10851230

1086-
// Set the zones used for the cluster. We call this in the loop as the default GCE zone
1087-
// is randomized to avoid zone exhaustion errors.
1088-
providerOpts, workloadProviderOpts = cfg.spec.SetRoachprodOptsZones(providerOpts, workloadProviderOpts, params, string(selectedArch))
1231+
// Set the zones used for this cluster create attempt. The planner keeps
1232+
// normal default-zone selection and capacity-error retry overrides in one
1233+
// place.
1234+
createZones := retryPlanner.zonesForAttempt()
1235+
providerOpts, workloadProviderOpts = cfg.spec.SetRoachprodOptsZones(
1236+
providerOpts, workloadProviderOpts, params.Cloud, createZones,
1237+
)
10891238
if clusterCloud != spec.Local {
10901239
providerOptsContainer.SetProviderOpts(clusterCloud.String(), providerOpts)
10911240
workloadProviderOptsContainer.SetProviderOpts(clusterCloud.String(), workloadProviderOpts)
@@ -1149,6 +1298,9 @@ func (f *clusterFactory) newRoachprodCluster(
11491298
}
11501299

11511300
clusterL.PrintfCtx(ctx, "cluster creation failed, cleaning up in case it was partially created: %s", err)
1301+
if i < maxAttempts {
1302+
retryPlanner.recordFailure(err, clusterL)
1303+
}
11521304
c.Destroy(ctx, closeLogger, clusterL)
11531305
if i >= maxAttempts {
11541306
return nil, nil, err

pkg/cmd/roachtest/spec/cluster_spec.go

Lines changed: 83 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -848,69 +848,110 @@ func (s *ClusterSpec) isLocalSSDAvailable(
848848
return nil
849849
}
850850

851-
// SetRoachprodOptsZones updates the providerOpts with the VM zones as specified in the params/spec.
852-
// We separate this logic from RoachprodOpts as we may need to call this multiple times in order to
853-
// randomize the default GCE zone.
851+
// RoachprodCreateZones returns the zones to use for a roachprod create attempt.
852+
func (s *ClusterSpec) RoachprodCreateZones(
853+
params RoachprodClusterConfig, arch vm.CPUArch,
854+
) []string {
855+
cloud := params.Cloud
856+
var zones []string
857+
if zonesStr, _ := s.roachprodOptsZonesString(params); zonesStr != "" {
858+
zones = strings.Split(zonesStr, ",")
859+
}
860+
if !s.Geo && len(zones) > 1 {
861+
zones = zones[:1]
862+
}
863+
if len(zones) == 0 {
864+
zones = DefaultZones(cloud, arch, s.Geo)
865+
}
866+
return append([]string(nil), zones...)
867+
}
868+
869+
// SetRoachprodOptsZones updates providerOpts with the zones for a roachprod
870+
// create attempt.
854871
func (s *ClusterSpec) SetRoachprodOptsZones(
855-
providerOpts, workloadProviderOpts vm.ProviderOpts, params RoachprodClusterConfig, arch string,
872+
providerOpts, workloadProviderOpts vm.ProviderOpts, cloud Cloud, zones []string,
856873
) (vm.ProviderOpts, vm.ProviderOpts) {
857-
zonesStr := params.Defaults.Zones
858-
cloud := params.Cloud
859874
switch cloud {
875+
case AWS:
876+
providerOpts.(*aws.ProviderOpts).CreateZones = zones
877+
workloadProviderOpts.(*aws.ProviderOpts).CreateZones = zones
878+
case GCE:
879+
providerOpts.(*gce.ProviderOpts).Zones = zones
880+
workloadProviderOpts.(*gce.ProviderOpts).Zones = zones
881+
case Azure:
882+
providerOpts.(*azure.ProviderOpts).Zones = zones
883+
workloadProviderOpts.(*azure.ProviderOpts).Zones = zones
884+
case IBM:
885+
providerOpts.(*ibm.ProviderOpts).CreateZones = zones
886+
workloadProviderOpts.(*ibm.ProviderOpts).CreateZones = zones
887+
}
888+
return providerOpts, workloadProviderOpts
889+
}
890+
891+
// DefaultZones returns the package-level roachprod default zones for the cloud.
892+
func DefaultZones(cloud Cloud, arch vm.CPUArch, geoDistributed bool) []string {
893+
switch cloud {
894+
case AWS:
895+
return aws.DefaultZones(geoDistributed)
896+
case GCE:
897+
return gce.DefaultZones(string(arch), geoDistributed)
898+
case Azure:
899+
return azure.DefaultZones(geoDistributed)
900+
case IBM:
901+
return ibm.DefaultZones(geoDistributed)
902+
default:
903+
return nil
904+
}
905+
}
906+
907+
// DefaultRetryZoneCandidates returns the provider's default non-geo zone pool.
908+
// Roachtest uses this to choose a different zone after provider capacity
909+
// failures.
910+
func DefaultRetryZoneCandidates(cloud Cloud, arch vm.CPUArch) []string {
911+
switch cloud {
912+
case AWS:
913+
return aws.DefaultRetryZoneCandidates()
914+
case GCE:
915+
return gce.DefaultRetryZoneCandidates(string(arch))
916+
case Azure:
917+
return azure.DefaultRetryZoneCandidates()
918+
default:
919+
return nil
920+
}
921+
}
922+
923+
func (s *ClusterSpec) roachprodOptsZonesString(params RoachprodClusterConfig) (string, bool) {
924+
zonesStr := params.Defaults.Zones
925+
explicit := zonesStr != ""
926+
switch params.Cloud {
860927
case AWS:
861928
if s.AWS.Zones != "" {
862929
zonesStr = s.AWS.Zones
930+
explicit = true
863931
}
864932
case GCE:
865933
if s.GCE.Zones != "" {
866934
zonesStr = s.GCE.Zones
935+
explicit = true
867936
}
868937
case Azure:
869938
if s.Azure.Zones != "" {
870939
zonesStr = s.Azure.Zones
940+
explicit = true
871941
}
872942
case IBM:
873943
if s.IBM.Zones != "" {
874944
zonesStr = s.IBM.Zones
945+
explicit = true
875946
}
876947
}
877-
var zones []string
878-
if zonesStr != "" {
879-
zones = strings.Split(zonesStr, ",")
880-
if !s.Geo {
881-
zones = zones[:1]
882-
}
883-
}
948+
return zonesStr, explicit
949+
}
884950

885-
switch cloud {
886-
case AWS:
887-
if len(zones) == 0 {
888-
zones = aws.DefaultZones(s.Geo)
889-
}
890-
providerOpts.(*aws.ProviderOpts).CreateZones = zones
891-
workloadProviderOpts.(*aws.ProviderOpts).CreateZones = zones
892-
case GCE:
893-
// We randomize the list of default zones for GCE for quota reasons, so decide the zone
894-
// early to ensure that the workload node and CRDB cluster have the same default zone.
895-
if len(zones) == 0 {
896-
zones = gce.DefaultZones(arch, s.Geo)
897-
}
898-
providerOpts.(*gce.ProviderOpts).Zones = zones
899-
workloadProviderOpts.(*gce.ProviderOpts).Zones = zones
900-
case Azure:
901-
if len(zones) == 0 {
902-
zones = azure.DefaultZones(s.Geo)
903-
}
904-
providerOpts.(*azure.ProviderOpts).Zones = zones
905-
workloadProviderOpts.(*azure.ProviderOpts).Zones = zones
906-
case IBM:
907-
if len(zones) == 0 {
908-
zones = ibm.DefaultZones(s.Geo)
909-
}
910-
providerOpts.(*ibm.ProviderOpts).CreateZones = zones
911-
workloadProviderOpts.(*ibm.ProviderOpts).CreateZones = zones
912-
}
913-
return providerOpts, workloadProviderOpts
951+
// UsesExplicitZones returns true when the cluster has user-specified zones.
952+
func (s *ClusterSpec) UsesExplicitZones(params RoachprodClusterConfig) bool {
953+
_, explicit := s.roachprodOptsZonesString(params)
954+
return explicit
914955
}
915956

916957
// Expiration is the lifetime of the cluster. It may be destroyed after

0 commit comments

Comments
 (0)