Skip to content

Commit 668287c

Browse files
committed
roachtest: smarter cluster creation retries
Previously, roachtest retried failed roachprod cluster creation up to 3 times, but they could randomly land back in the same unavailable zone. That made capacity recovery depend on chance, even when the cloud provider error explicitly named other zones with available capacity. This change has two parts: 1. Roachprod now classifies zonal capacity failures as `vm.CreateCapacityError`. The structured error carries the provider, machine type, failed zone, suggested zones when available, and a capacity class that lets callers distinguish zonal capacity failures from other (yet unimplemented) retry policies. 2. Roachtest now uses a `createRetryPlanner` during cluster creation. The planner keeps the existing retry loop, but when it sees a zonal capacity error it steers the next create attempt into a provider-suggested or otherwise untried zone. It preserves the existing guard rails for explicit zones and geo-distributed cluster specs. In nightlies smoke tests, this recovered 26 cluster creates that otherwise failed on capacity errors: 23 on GCE and 3 on AWS. Epic: none Release note: None
1 parent 3bc020b commit 668287c

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)