Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 17 additions & 16 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,9 +545,9 @@ pscale role create <database> <branch> <name> --org <org> --format json --inheri
pscale role create <database> <branch> <name> --org <org> --format json --inherited-roles neki_viewer,pg_read_all_data
pscale role create <database> <branch> <name> --org <org> --format json --inherited-roles neki_operator,postgres

# Role connection details for a branch replica, read-only replica, or PgBouncer
# Role connection details for a branch replica, dedicated read replica, or PgBouncer
pscale role get <database> <branch> <role-id> --org <org> --format json --replica
pscale role get <database> <branch> <role-id> --org <org> --format json --read-only-replica <replica-name>
pscale role get <database> <branch> <role-id> --org <org> --format json --dedicated-read-replica <replica-name>
pscale role get <database> <branch> <role-id> --org <org> --format json --bouncer <bouncer-name>

# Neki: router is a username suffix; replica and shard are libpq options on the URL
Expand All @@ -573,34 +573,35 @@ pscale branch resize cancel <database> <branch> --org <org> --format json

- At least one of `--cluster-size`, `--replicas`, or `--parameters` is required.
- Neki `--inherited-roles` may include `neki_viewer` (must also include `pg_read_all_data`) and `neki_operator` (must also include `postgres`). The API rejects those pairings if the required role is missing.
- `--replica`, `--read-only-replica`, and `--bouncer` are mutually exclusive. `--read-only-replica` and `--bouncer` are Postgres-only and cannot combine with `--router` or `--shard`. On Neki, `--replica`, `--shard`, and `--router` can be combined. `--router` rewrites `username` to `user|<name>`. `--replica` and `--shard` set `options` (`-c __neki.target=REPLICA`, `-c __neki.shard=…`) and add them to `database_url`. List names with `pscale branch router list` and `pscale branch shard list` (use the shard name, not the API id).
- `--replica`, `--dedicated-read-replica`, and `--bouncer` are mutually exclusive. `--dedicated-read-replica` and `--bouncer` are Postgres-only and cannot combine with `--router` or `--shard`. The deprecated `--read-only-replica` flag is a compatibility alias for `--dedicated-read-replica`. On Neki, `--replica`, `--shard`, and `--router` can be combined. `--router` rewrites `username` to `user|<name>`. `--replica` and `--shard` set `options` (`-c __neki.target=REPLICA`, `-c __neki.shard=…`) and add them to `database_url`. List names with `pscale branch router list` and `pscale branch shard list` (use the shard name, not the API id).
- `--parameters` values are validated against the catalog before submission; unknown or immutable parameters fail fast. Parameters with `"restart": true` in the catalog restart the database when applied — surface this to the user before changing them.
- Change request `state` is one of `queued`, `pending`, `resizing`, `completed`, `canceled`. Only `completed` and `canceled` are terminal. Without `--wait`, poll `resize status` instead of assuming completion.
- A no-op (branch already matches the requested configuration) prints `{"result": "no_change", "branch": "<branch>"}` in JSON mode instead of a change request.
- `resize cancel` prints `{"result": "canceled", "branch": "<branch>"}` in JSON mode.
- MySQL databases are rejected: use `pscale keyspace resize` for Vitess keyspaces.

## Postgres read-only replicas
## Postgres dedicated read replicas

Read-only replicas provide dedicated regional capacity for Postgres queries
that can tolerate replication lag. They are separate from the replicas in the
primary branch cluster and from Vitess read-only regions.
Dedicated read replicas provide dedicated regional capacity for Postgres
queries that can tolerate replication lag. They are separate from the replicas
in the primary branch cluster and from Vitess read-only regions.

```bash
pscale read-only-replica list <database> <branch> --org <org> --format json
pscale read-only-replica show <database> <branch> <name> --org <org> --format json
pscale read-only-replica create <database> <branch> <name> --region <region> --org <org> --format json
pscale read-only-replica create <database> <branch> <name> --region <region> --replicas 2 --cluster-size PS_10_GCP_X86 --org <org> --format json
pscale read-only-replica update <database> <branch> <name> --replicas 3 --org <org> --format json
pscale read-only-replica update <database> <branch> <name> --cluster-size PS_20_GCP_X86 --parameters pgconf.max_connections=300 --org <org> --format json
pscale read-only-replica delete <database> <branch> <name> --org <org> --format json --force
pscale dedicated-read-replica list <database> <branch> --org <org> --format json
pscale dedicated-read-replica show <database> <branch> <name> --org <org> --format json
pscale dedicated-read-replica create <database> <branch> <name> --region <region> --org <org> --format json
pscale dedicated-read-replica create <database> <branch> <name> --region <region> --replicas 2 --cluster-size PS_10_GCP_X86 --org <org> --format json
pscale dedicated-read-replica update <database> <branch> <name> --replicas 3 --org <org> --format json
pscale dedicated-read-replica update <database> <branch> <name> --cluster-size PS_20_GCP_X86 --parameters pgconf.max_connections=300 --org <org> --format json
pscale dedicated-read-replica delete <database> <branch> <name> --org <org> --format json --force
```

- `create` requires a name and `--region`. The API defaults to one instance and the primary cluster size when `--replicas` and `--cluster-size` are omitted.
- `show`, `update`, and `delete` identify the read-only replica by name.
- `show`, `update`, and `delete` identify the dedicated read replica by name.
- `update` requires at least one of `--replicas`, `--cluster-size`, or repeatable `--parameters namespace.name=value`. Parameter values must be greater than or equal to the primary's corresponding values.
- Creating and updating replicas is asynchronous; inspect `state` and `ready` in the returned object or with `list`.
- Creating and updating dedicated read replicas is asynchronous; inspect `state` and `ready` in the returned object or with `list`.
- `delete` requires explicit approval before using `--force`.
- `read-only-replica` remains available as a backwards-compatible command alias. It prints a deprecation warning on stderr; stdout is unchanged.
- PostgreSQL only. For Vitess/MySQL, use `pscale keyspace read-only-regions`.

## Postgres switchovers
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package readonlyreplica
package dedicatedreadreplica

import (
"fmt"
Expand All @@ -9,7 +9,7 @@ import (
"github.com/spf13/cobra"
)

// CreateCmd creates a read-only replica.
// CreateCmd creates a dedicated read replica.
func CreateCmd(ch *cmdutil.Helper) *cobra.Command {
var flags struct {
region string
Expand All @@ -19,8 +19,8 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command {

cmd := &cobra.Command{
Use: "create <database> <branch> <name>",
Short: "Create a read-only replica",
Long: `Create a read-only replica for a PostgreSQL database branch.
Short: "Create a dedicated read replica",
Long: `Create a dedicated read replica for a PostgreSQL database branch.

Region is required. The replica count defaults to 1 and the cluster size
defaults to the primary cluster size when those flags are omitted.`,
Expand All @@ -33,7 +33,7 @@ defaults to the primary cluster size when those flags are omitted.`,
if err != nil {
return err
}
if err := cmdutil.RequirePostgresDatabase(ctx, client, ch.Config.Organization, database, "read-only replicas"); err != nil {
if err := cmdutil.RequirePostgresDatabase(ctx, client, ch.Config.Organization, database, "dedicated read replicas"); err != nil {
return err
}

Expand All @@ -49,7 +49,7 @@ defaults to the primary cluster size when those flags are omitted.`,
req.Replicas = &flags.replicas
}

end := ch.Printer.PrintProgress(fmt.Sprintf("Creating read-only replica %s for %s/%s", printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch)))
end := ch.Printer.PrintProgress(fmt.Sprintf("Creating dedicated read replica %s for %s/%s", printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch)))
defer end()

replica, err := client.PostgresReadOnlyReplicas.Create(ctx, req)
Expand All @@ -65,15 +65,15 @@ defaults to the primary cluster size when those flags are omitted.`,
end()

if ch.Printer.Format() == printer.Human {
ch.Printer.Printf("Read-only replica %s is being created for %s/%s (state: %s).\n",
ch.Printer.Printf("Dedicated read replica %s is being created for %s/%s (state: %s).\n",
printer.BoldBlue(replica.Name), printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(replica.State))
return nil
}
return ch.Printer.PrintResource(toReadOnlyReplica(replica))
},
}

cmd.Flags().StringVar(&flags.region, "region", "", "Region slug for the read-only replica")
cmd.Flags().StringVar(&flags.region, "region", "", "Region slug for the dedicated read replica")
cmd.Flags().IntVar(&flags.replicas, "replicas", 1, "Number of instances serving reads")
cmd.Flags().StringVar(&flags.clusterSize, "cluster-size", "", "Cluster size SKU; defaults to the primary cluster size")
cmd.MarkFlagRequired("region") // nolint:errcheck
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package readonlyreplica
package dedicatedreadreplica

import (
"encoding/json"
Expand All @@ -9,15 +9,35 @@ import (
"github.com/spf13/cobra"
)

// Cmd manages read-only replicas for Postgres branches.
// Cmd manages dedicated read replicas for Postgres branches.
func Cmd(ch *cmdutil.Helper) *cobra.Command {
return cmd(ch)
}

const deprecatedName = "read-only-replica"

const deprecationMessage = "use dedicated-read-replica instead"

// DeprecatedCmd preserves the previous command name without advertising it.
// Cobra only prints Deprecated for the command that runs, so subcommands warn
// through PersistentPreRunE the same way the deprecated workflow command does.
func DeprecatedCmd(ch *cmdutil.Helper) *cobra.Command {
cmd := cmd(ch)
cmd.Use = deprecatedName + " <command>"
cmd.Hidden = true
cmd.Deprecated = deprecationMessage
cmd.PersistentPreRunE = cmdutil.WarnDeprecated(deprecatedName, deprecationMessage, cmdutil.CheckAuthentication(ch.Config))
return cmd
}

func cmd(ch *cmdutil.Helper) *cobra.Command {
cmd := &cobra.Command{
Use: "read-only-replica <command>",
Short: "Manage read-only replicas for a Postgres branch",
Long: `Manage read-only replicas for a PostgreSQL database branch.
Use: "dedicated-read-replica <command>",
Short: "Manage dedicated read replicas for a Postgres branch",
Long: `Manage dedicated read replicas for a PostgreSQL database branch.

Read-only replicas provide dedicated capacity for queries that can tolerate
replication lag. They accept read traffic only.
Dedicated read replicas provide dedicated capacity for queries that can
tolerate replication lag. They accept read traffic only.

This command is only available for PostgreSQL databases.`,
PersistentPreRunE: cmdutil.CheckAuthentication(ch.Config),
Expand All @@ -35,7 +55,7 @@ This command is only available for PostgreSQL databases.`,
return cmd
}

// ReadOnlyReplica is the human/JSON/CSV view of a Postgres read-only replica.
// ReadOnlyReplica is the human/JSON/CSV view of a Postgres dedicated read replica.
type ReadOnlyReplica struct {
ID string `header:"id" json:"id"`
Name string `header:"name" json:"name"`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package readonlyreplica
package dedicatedreadreplica

import (
"bytes"
"context"
"encoding/json"
"testing"
"time"

Expand Down Expand Up @@ -62,6 +63,51 @@ func databaseService(c *qt.C, org, database string) *mock.DatabaseService {
}
}

func TestCmdUsesDedicatedReadReplicaName(t *testing.T) {
c := qt.New(t)

cmd := Cmd(&cmdutil.Helper{Config: &config.Config{}})

c.Assert(cmd.Use, qt.Equals, "dedicated-read-replica <command>")
c.Assert(cmd.Aliases, qt.HasLen, 0)
}

func TestDeprecatedCmdIsHidden(t *testing.T) {
c := qt.New(t)

cmd := DeprecatedCmd(&cmdutil.Helper{Config: &config.Config{}})

c.Assert(cmd.Use, qt.Equals, "read-only-replica <command>")
c.Assert(cmd.Hidden, qt.IsTrue)
c.Assert(cmd.Deprecated, qt.Equals, "use dedicated-read-replica instead")
}

func TestDeprecatedCmdWarnsOnSubcommand(t *testing.T) {
c := qt.New(t)
var stdout, stderr bytes.Buffer
org, database, branch := "planetscale", "mydb", "main"
svc := &mock.PostgresReadOnlyReplicasService{
ListFn: func(ctx context.Context, req *ps.ListPostgresReadOnlyReplicasRequest) ([]*ps.PostgresReadOnlyReplica, error) {
return []*ps.PostgresReadOnlyReplica{testReplica()}, nil
},
}

ch := testHelper(org, databaseService(c, org, database), svc, printer.JSON, &stdout)
ch.Config.AccessToken = "token"

cmd := DeprecatedCmd(ch)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"list", database, branch, "--org", org})
c.Assert(cmd.Execute(), qt.IsNil)
c.Assert(svc.ListFnInvoked, qt.IsTrue)
c.Assert(stderr.String(), qt.Equals, "Command \"read-only-replica\" is deprecated, use dedicated-read-replica instead\n")

var got []map[string]any
c.Assert(json.Unmarshal(stdout.Bytes(), &got), qt.IsNil)
c.Assert(got, qt.HasLen, 1)
c.Assert(got[0]["name"], qt.Equals, "analytics")
}

func TestListCmd(t *testing.T) {
c := qt.New(t)
var buf bytes.Buffer
Expand Down Expand Up @@ -192,7 +238,7 @@ func TestDeleteCmd(t *testing.T) {
c.Assert(cmd.Execute(), qt.IsNil)
c.Assert(svc.DeleteFnInvoked, qt.IsTrue)
c.Assert(buf.String(), qt.JSONEquals, map[string]string{
"result": "read-only replica deleted",
"result": "dedicated read replica deleted",
"name": "analytics",
"database": database,
"branch": branch,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package readonlyreplica
package dedicatedreadreplica

import (
"fmt"
Expand All @@ -9,13 +9,13 @@ import (
"github.com/spf13/cobra"
)

// DeleteCmd deletes a read-only replica by name.
// DeleteCmd deletes a dedicated read replica by name.
func DeleteCmd(ch *cmdutil.Helper) *cobra.Command {
var force bool

cmd := &cobra.Command{
Use: "delete <database> <branch> <name>",
Short: "Delete a read-only replica",
Short: "Delete a dedicated read replica",
Args: cmdutil.RequiredArgs("database", "branch", "name"),
Aliases: []string{"rm"},
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -26,18 +26,18 @@ func DeleteCmd(ch *cmdutil.Helper) *cobra.Command {
if err != nil {
return err
}
if err := cmdutil.RequirePostgresDatabase(ctx, client, ch.Config.Organization, database, "read-only replicas"); err != nil {
if err := cmdutil.RequirePostgresDatabase(ctx, client, ch.Config.Organization, database, "dedicated read replicas"); err != nil {
return err
}

if !force {
confirmationName := fmt.Sprintf("%s/%s/%s", database, branch, name)
if err := ch.Printer.ConfirmCommand(confirmationName, "delete read-only replica", "deletion of read-only replica"); err != nil {
if err := ch.Printer.ConfirmCommand(confirmationName, "delete dedicated read replica", "deletion of dedicated read replica"); err != nil {
return err
}
}

end := ch.Printer.PrintProgress(fmt.Sprintf("Deleting read-only replica %s from %s/%s", printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch)))
end := ch.Printer.PrintProgress(fmt.Sprintf("Deleting dedicated read replica %s from %s/%s", printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch)))
defer end()

err = client.PostgresReadOnlyReplicas.Delete(ctx, &ps.DeletePostgresReadOnlyReplicaRequest{
Expand All @@ -49,7 +49,7 @@ func DeleteCmd(ch *cmdutil.Helper) *cobra.Command {
if err != nil {
switch cmdutil.ErrCode(err) {
case ps.ErrNotFound:
return fmt.Errorf("read-only replica %s does not exist on %s/%s (organization: %s)",
return fmt.Errorf("dedicated read replica %s does not exist on %s/%s (organization: %s)",
printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(ch.Config.Organization))
default:
return cmdutil.HandleError(err)
Expand All @@ -58,20 +58,20 @@ func DeleteCmd(ch *cmdutil.Helper) *cobra.Command {
end()

if ch.Printer.Format() == printer.Human {
ch.Printer.Printf("Read-only replica %s was successfully deleted from %s/%s.\n",
ch.Printer.Printf("Dedicated read replica %s was successfully deleted from %s/%s.\n",
printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch))
return nil
}

return ch.Printer.PrintResource(map[string]string{
"result": "read-only replica deleted",
"result": "dedicated read replica deleted",
"name": name,
"database": database,
"branch": branch,
})
},
}

cmd.Flags().BoolVar(&force, "force", false, "Delete a read-only replica without confirmation")
cmd.Flags().BoolVar(&force, "force", false, "Delete a dedicated read replica without confirmation")
return cmd
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package readonlyreplica
package dedicatedreadreplica

import (
"fmt"
Expand All @@ -9,11 +9,11 @@ import (
"github.com/spf13/cobra"
)

// ListCmd lists read-only replicas for a Postgres branch.
// ListCmd lists dedicated read replicas for a Postgres branch.
func ListCmd(ch *cmdutil.Helper) *cobra.Command {
cmd := &cobra.Command{
Use: "list <database> <branch>",
Short: "List read-only replicas for a Postgres branch",
Short: "List dedicated read replicas for a Postgres branch",
Args: cmdutil.RequiredArgs("database", "branch"),
Aliases: []string{"ls"},
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -24,11 +24,11 @@ func ListCmd(ch *cmdutil.Helper) *cobra.Command {
if err != nil {
return err
}
if err := cmdutil.RequirePostgresDatabase(ctx, client, ch.Config.Organization, database, "read-only replicas"); err != nil {
if err := cmdutil.RequirePostgresDatabase(ctx, client, ch.Config.Organization, database, "dedicated read replicas"); err != nil {
return err
}

end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching read-only replicas for %s/%s", printer.BoldBlue(database), printer.BoldBlue(branch)))
end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching dedicated read replicas for %s/%s", printer.BoldBlue(database), printer.BoldBlue(branch)))
defer end()

replicas, err := client.PostgresReadOnlyReplicas.List(ctx, &ps.ListPostgresReadOnlyReplicasRequest{
Expand All @@ -48,7 +48,7 @@ func ListCmd(ch *cmdutil.Helper) *cobra.Command {
end()

if len(replicas) == 0 && ch.Printer.Format() == printer.Human {
ch.Printer.Printf("No read-only replicas exist for %s/%s.\n", printer.BoldBlue(database), printer.BoldBlue(branch))
ch.Printer.Printf("No dedicated read replicas exist for %s/%s.\n", printer.BoldBlue(database), printer.BoldBlue(branch))
return nil
}

Expand Down
Loading
Loading