From 7a6c2a552837594f8918362bab84849a3896c93a Mon Sep 17 00:00:00 2001 From: Mike Coutermarsh Date: Sat, 10 Oct 2026 11:51:43 -0400 Subject: [PATCH 1/2] Wait for the schema snapshot after refresh-schema refresh-schema returned as soon as a new snapshot was created, so a follow-up command that reads it could run before the snapshot was ready. Poll until schema_ready is true, and treat a snapshot that is still refreshing as action_required. Co-authored-by: Cursor --- internal/cmd/branch/refresh_schema.go | 64 +++++++++++++++-- internal/cmd/branch/refresh_schema_test.go | 84 ++++++++++++++++++++++ internal/cmdutil/json_error.go | 9 +++ internal/cmdutil/json_error_test.go | 14 ++++ internal/planetscale/branches.go | 1 + 5 files changed, 165 insertions(+), 7 deletions(-) diff --git a/internal/cmd/branch/refresh_schema.go b/internal/cmd/branch/refresh_schema.go index f50ea7b4..0d859bd0 100644 --- a/internal/cmd/branch/refresh_schema.go +++ b/internal/cmd/branch/refresh_schema.go @@ -1,7 +1,10 @@ package branch import ( + "context" + "errors" "fmt" + "time" "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/planetscale" @@ -9,10 +12,17 @@ import ( "github.com/spf13/cobra" ) +// schemaRefreshPollInterval and schemaRefreshTimeout are vars so tests can shorten the wait. +var ( + schemaRefreshPollInterval = 2 * time.Second + schemaRefreshTimeout = 2 * time.Minute +) + func RefreshSchemaCmd(ch *cmdutil.Helper) *cobra.Command { cmd := &cobra.Command{ Use: "refresh-schema ", Short: "Refresh the schema for a MySQL branch", + Long: "Refresh the schema for a MySQL branch. Waits until the updated schema snapshot is ready so later commands, such as routing-rules, see it.", Args: cmdutil.RequiredArgs("database", "branch"), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -32,13 +42,11 @@ func RefreshSchemaCmd(ch *cmdutil.Helper) *cobra.Command { Branch: branch, }) if err != nil { - switch cmdutil.ErrCode(err) { - case planetscale.ErrNotFound: - return fmt.Errorf("branch %s does not exist in database %s (organization: %s)", - printer.BoldBlue(branch), printer.BoldBlue(database), printer.BoldBlue(ch.Config.Organization)) - default: - return cmdutil.HandleError(err) - } + return branchNotFoundOr(err, branch, database, ch.Config.Organization) + } + + if err := waitUntilSchemaReady(ctx, client, ch.Config.Organization, database, branch); err != nil { + return branchNotFoundOr(err, branch, database, ch.Config.Organization) } end() @@ -57,3 +65,45 @@ func RefreshSchemaCmd(ch *cmdutil.Helper) *cobra.Command { return cmd } + +func branchNotFoundOr(err error, branch, database, org string) error { + if cmdutil.ErrCode(err) == planetscale.ErrNotFound { + return fmt.Errorf("branch %s does not exist in database %s (organization: %s)", + printer.BoldBlue(branch), printer.BoldBlue(database), printer.BoldBlue(org)) + } + return cmdutil.HandleError(err) +} + +// waitUntilSchemaReady polls until the branch schema snapshot is ready. +// refresh-schema points the branch at a new snapshot before that snapshot +// is ready, and commands such as routing-rules fail during that window. +func waitUntilSchemaReady(ctx context.Context, client *planetscale.Client, org, database, branch string) error { + ctx, cancel := context.WithTimeout(ctx, schemaRefreshTimeout) + defer cancel() + + ticker := time.NewTicker(schemaRefreshPollInterval) + defer ticker.Stop() + + for { + resp, err := client.DatabaseBranches.Get(ctx, &planetscale.GetDatabaseBranchRequest{ + Organization: org, + Database: database, + Branch: branch, + }) + if err != nil && cmdutil.ErrCode(err) == planetscale.ErrNotFound { + return err + } + if err == nil && resp.SchemaReady { + return nil + } + + select { + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return errors.New("schema refreshing; please try again in a few moments") + } + return ctx.Err() + case <-ticker.C: + } + } +} diff --git a/internal/cmd/branch/refresh_schema_test.go b/internal/cmd/branch/refresh_schema_test.go index 612448cf..cc96c328 100644 --- a/internal/cmd/branch/refresh_schema_test.go +++ b/internal/cmd/branch/refresh_schema_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "testing" + "time" qt "github.com/frankban/quicktest" "github.com/planetscale/cli/internal/cmdutil" @@ -32,6 +33,12 @@ func TestSnapshot_CreateCmd(t *testing.T) { c.Assert(req.Branch, qt.Equals, branch) return nil }, + GetFn: func(ctx context.Context, req *ps.GetDatabaseBranchRequest) (*ps.DatabaseBranch, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, db) + c.Assert(req.Branch, qt.Equals, branch) + return &ps.DatabaseBranch{Name: branch, SchemaReady: true}, nil + }, } ch := &cmdutil.Helper{ @@ -52,4 +59,81 @@ func TestSnapshot_CreateCmd(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(svc.RefreshSchemaFnInvoked, qt.IsTrue) + c.Assert(svc.GetFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.Contains, `"result": "schema refreshed"`) +} + +func TestRefreshSchemaWaitsUntilSchemaReady(t *testing.T) { + c := qt.New(t) + restoreSchemaRefreshTiming(t) + + schemaRefreshPollInterval = time.Millisecond + schemaRefreshTimeout = time.Second + + var buf bytes.Buffer + var polls int + svc := &mock.DatabaseBranchesService{ + RefreshSchemaFn: func(context.Context, *ps.RefreshSchemaRequest) error { + return nil + }, + GetFn: func(context.Context, *ps.GetDatabaseBranchRequest) (*ps.DatabaseBranch, error) { + polls++ + return &ps.DatabaseBranch{SchemaReady: polls > 1}, nil + }, + } + + cmd := RefreshSchemaCmd(refreshSchemaHelper(svc, &buf)) + cmd.SetArgs([]string{"planetscale", "development"}) + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + c.Assert(polls, qt.Equals, 2) + c.Assert(buf.String(), qt.Contains, `"result": "schema refreshed"`) +} + +func TestRefreshSchemaTimesOutWhileSchemaIsRefreshing(t *testing.T) { + c := qt.New(t) + restoreSchemaRefreshTiming(t) + + schemaRefreshPollInterval = time.Millisecond + schemaRefreshTimeout = 50 * time.Millisecond + + var buf bytes.Buffer + svc := &mock.DatabaseBranchesService{ + RefreshSchemaFn: func(context.Context, *ps.RefreshSchemaRequest) error { + return nil + }, + GetFn: func(context.Context, *ps.GetDatabaseBranchRequest) (*ps.DatabaseBranch, error) { + return &ps.DatabaseBranch{SchemaReady: false}, nil + }, + } + + cmd := RefreshSchemaCmd(refreshSchemaHelper(svc, &buf)) + cmd.SetArgs([]string{"planetscale", "development"}) + err := cmd.Execute() + + c.Assert(err, qt.ErrorMatches, "schema refreshing; please try again in a few moments") + c.Assert(buf.String(), qt.Equals, "") +} + +func refreshSchemaHelper(svc *mock.DatabaseBranchesService, buf *bytes.Buffer) *cmdutil.Helper { + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(buf) + return &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{DatabaseBranches: svc}, nil + }, + } +} + +func restoreSchemaRefreshTiming(t *testing.T) { + t.Helper() + interval, timeout := schemaRefreshPollInterval, schemaRefreshTimeout + t.Cleanup(func() { + schemaRefreshPollInterval = interval + schemaRefreshTimeout = timeout + }) } diff --git a/internal/cmdutil/json_error.go b/internal/cmdutil/json_error.go index 055689b4..eb73f4d4 100644 --- a/internal/cmdutil/json_error.go +++ b/internal/cmdutil/json_error.go @@ -177,6 +177,15 @@ func GlobalJSONError(err error) JSONErrorResponse { AgentAuthCheckCmd(), } + // refresh-schema timed out before the new snapshot was ready. The branch + // exists; retry the follow-up command once the snapshot finishes. + case strings.Contains(lower, "schema refreshing"): + status = "action_required" + code = "schema_snapshot_not_ready" + nextSteps = []string{ + "Wait for the branch schema snapshot to become ready, then retry", + } + // Unclassified: auth state is the one thing worth ruling out first. default: nextSteps = []string{ diff --git a/internal/cmdutil/json_error_test.go b/internal/cmdutil/json_error_test.go index f2069285..dd0e0630 100644 --- a/internal/cmdutil/json_error_test.go +++ b/internal/cmdutil/json_error_test.go @@ -174,6 +174,20 @@ func TestGlobalJSONErrorSchemaMutationBlocked(t *testing.T) { } } +func TestGlobalJSONErrorSchemaRefreshing(t *testing.T) { + resp := GlobalJSONError(errors.New("schema refreshing; please try again in a few moments")) + + if resp.Status != "action_required" { + t.Fatalf("status = %q", resp.Status) + } + if resp.Code() != "schema_snapshot_not_ready" { + t.Fatalf("code = %q", resp.Code()) + } + if len(resp.NextSteps) != 1 || resp.NextSteps[0] != "Wait for the branch schema snapshot to become ready, then retry" { + t.Fatalf("next_steps = %#v", resp.NextSteps) + } +} + func TestGlobalJSONErrorSchemaSnapshotNotReady(t *testing.T) { resp := GlobalJSONError(&planetscale.Error{ APICode: "schema_snapshot_not_ready", diff --git a/internal/planetscale/branches.go b/internal/planetscale/branches.go index 367e6123..54d71fa3 100644 --- a/internal/planetscale/branches.go +++ b/internal/planetscale/branches.go @@ -24,6 +24,7 @@ type DatabaseBranch struct { Actor Actor `json:"actor"` Region Region `json:"region"` Ready bool `json:"ready"` + SchemaReady bool `json:"schema_ready"` Production bool `json:"production"` HtmlURL string `json:"html_url"` CreatedAt time.Time `json:"created_at"` From 72cfe3c83df732f6c7e0a53e02a07e7021530673 Mon Sep 17 00:00:00 2001 From: Mike Coutermarsh Date: Sat, 10 Oct 2026 13:44:54 -0400 Subject: [PATCH 2/2] Return poll failures from refresh-schema instead of a refresh timeout A branch lookup that fails while waiting for the snapshot was reported as schema refreshing once the wait elapsed. Surface that error instead. Co-authored-by: Cursor --- internal/cmd/branch/refresh_schema.go | 14 +++++++++--- internal/cmd/branch/refresh_schema_test.go | 26 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/internal/cmd/branch/refresh_schema.go b/internal/cmd/branch/refresh_schema.go index 0d859bd0..52b04f81 100644 --- a/internal/cmd/branch/refresh_schema.go +++ b/internal/cmd/branch/refresh_schema.go @@ -84,21 +84,29 @@ func waitUntilSchemaReady(ctx context.Context, client *planetscale.Client, org, ticker := time.NewTicker(schemaRefreshPollInterval) defer ticker.Stop() + var pollErr error for { resp, err := client.DatabaseBranches.Get(ctx, &planetscale.GetDatabaseBranchRequest{ Organization: org, Database: database, Branch: branch, }) - if err != nil && cmdutil.ErrCode(err) == planetscale.ErrNotFound { + switch { + case err != nil && cmdutil.ErrCode(err) == planetscale.ErrNotFound: return err - } - if err == nil && resp.SchemaReady { + case err != nil && ctx.Err() == nil: + pollErr = err + case err == nil && resp.SchemaReady: return nil + case err == nil: + pollErr = nil } select { case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) && pollErr != nil { + return pollErr + } if errors.Is(ctx.Err(), context.DeadlineExceeded) { return errors.New("schema refreshing; please try again in a few moments") } diff --git a/internal/cmd/branch/refresh_schema_test.go b/internal/cmd/branch/refresh_schema_test.go index cc96c328..a26d14cb 100644 --- a/internal/cmd/branch/refresh_schema_test.go +++ b/internal/cmd/branch/refresh_schema_test.go @@ -3,6 +3,7 @@ package branch import ( "bytes" "context" + "errors" "testing" "time" @@ -91,6 +92,31 @@ func TestRefreshSchemaWaitsUntilSchemaReady(t *testing.T) { c.Assert(buf.String(), qt.Contains, `"result": "schema refreshed"`) } +func TestRefreshSchemaReturnsPollError(t *testing.T) { + c := qt.New(t) + restoreSchemaRefreshTiming(t) + + schemaRefreshPollInterval = time.Millisecond + schemaRefreshTimeout = 50 * time.Millisecond + + var buf bytes.Buffer + svc := &mock.DatabaseBranchesService{ + RefreshSchemaFn: func(context.Context, *ps.RefreshSchemaRequest) error { + return nil + }, + GetFn: func(context.Context, *ps.GetDatabaseBranchRequest) (*ps.DatabaseBranch, error) { + return nil, errors.New("connection refused") + }, + } + + cmd := RefreshSchemaCmd(refreshSchemaHelper(svc, &buf)) + cmd.SetArgs([]string{"planetscale", "development"}) + err := cmd.Execute() + + c.Assert(err, qt.ErrorMatches, "connection refused") + c.Assert(buf.String(), qt.Equals, "") +} + func TestRefreshSchemaTimesOutWhileSchemaIsRefreshing(t *testing.T) { c := qt.New(t) restoreSchemaRefreshTiming(t)