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
7 changes: 5 additions & 2 deletions internal/cmd/branch/connections.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import (
func ConnectionsCmd(ch *cmdutil.Helper) *cobra.Command {
cmd := &cobra.Command{
Use: "connections <command>",
Short: "Show and kill MySQL or Postgres branch connections",
Long: `Show and kill MySQL or Postgres branch connections. Neki is not supported.
Short: "Show and kill branch connections",
Long: `Show and kill MySQL or Postgres branch connections, and show Neki branch connections.

MySQL (Vitess):
show/kill/top with --keyspace and --shard.
Expand All @@ -20,6 +20,9 @@ Postgres:
show/top with --instance or --role.
kill-transaction terminates a transaction_id from show.

Neki:
show/top list client sessions across the branch's routers. Read-only.

Agent workflow:
1. Run: pscale branch connections show <database> <branch> --format json
2. Inspect query_id, transaction_id, and connection_id from the selected row.
Expand Down
61 changes: 61 additions & 0 deletions internal/cmd/branch/connections/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package connections

import (
"context"
"errors"
"fmt"
"sync"

"github.com/planetscale/cli/internal/cmdutil"
live "github.com/planetscale/cli/internal/connections"
Expand Down Expand Up @@ -39,6 +41,65 @@ func databaseNotFoundError(ch *cmdutil.Helper, database string) error {
printer.BoldBlue(database), printer.BoldBlue(ch.Config.Organization))
}

// notFoundExplainer replaces the connections endpoint's ambiguous 404 with
// whether the branch is missing or just lacks live connections. The answer is
// cached so a refreshing TUI does not repeat the lookups every interval.
type notFoundExplainer struct {
ch *cmdutil.Helper
database string
branch string

once sync.Once
explained error
}

func newNotFoundExplainer(ch *cmdutil.Helper, database, branch string) *notFoundExplainer {
return &notFoundExplainer{ch: ch, database: database, branch: branch}
}

func (e *notFoundExplainer) explain(ctx context.Context, err error) error {
var notFound *live.NotFoundError
if e == nil || !errors.As(err, &notFound) {
return err
}
e.once.Do(func() {
e.explained = e.resolve(ctx, err)
})
return e.explained
}

func (e *notFoundExplainer) resolve(ctx context.Context, err error) error {
client, clientErr := e.ch.Client()
if clientErr != nil {
return err
}
org := e.ch.Config.Organization
db, dbErr := client.Databases.Get(ctx, &ps.GetDatabaseRequest{Organization: org, Database: e.database})
if cmdutil.ErrCode(dbErr) == ps.ErrNotFound {
return databaseNotFoundError(e.ch, e.database)
}
if dbErr != nil || db == nil {
return err
}

var branchErr error
if db.Kind == ps.DatabaseEngineMySQL {
_, branchErr = client.DatabaseBranches.Get(ctx, &ps.GetDatabaseBranchRequest{Organization: org, Database: e.database, Branch: e.branch})
} else {
_, branchErr = client.PostgresBranches.Get(ctx, &ps.GetPostgresBranchRequest{Organization: org, Database: e.database, Branch: e.branch})
}
switch {
case cmdutil.ErrCode(branchErr) == ps.ErrNotFound:
return fmt.Errorf("branch %s does not exist in database %s (organization: %s)",
printer.BoldBlue(e.branch), printer.BoldBlue(e.database), printer.BoldBlue(org))
case branchErr != nil:
return err
default:
return fmt.Errorf("live connections are not enabled for branch %s in database %s (organization: %s)",
printer.BoldBlue(e.branch), printer.BoldBlue(e.database), printer.BoldBlue(org))
}
}

func newConnectionsClient(ch *cmdutil.Helper, database, branch string, target ConnectionTarget) (*live.Client, error) {
return live.NewClient(live.ClientConfig{
BaseURL: ch.Config.BaseURL,
Expand Down
56 changes: 56 additions & 0 deletions internal/cmd/branch/connections/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package connections

import (
"context"
"errors"
"testing"

qt "github.com/frankban/quicktest"
"github.com/planetscale/cli/internal/cmdutil"
"github.com/planetscale/cli/internal/config"
live "github.com/planetscale/cli/internal/connections"
"github.com/planetscale/cli/internal/mock"
ps "github.com/planetscale/cli/internal/planetscale"
)

func TestNotFoundExplainerLooksUpBranchOnce(t *testing.T) {
c := qt.New(t)
lookups := 0
branches := &mock.DatabaseBranchesService{
GetFn: func(context.Context, *ps.GetDatabaseBranchRequest) (*ps.DatabaseBranch, error) {
lookups++
return &ps.DatabaseBranch{Name: "main"}, nil
},
}
ch := &cmdutil.Helper{
Config: &config.Config{Organization: "acme"},
Client: func() (*ps.Client, error) {
return &ps.Client{
Databases: &mock.DatabaseService{
GetFn: func(_ context.Context, req *ps.GetDatabaseRequest) (*ps.Database, error) {
return &ps.Database{Name: req.Database, Kind: ps.DatabaseEngineMySQL}, nil
},
},
DatabaseBranches: branches,
}, nil
},
}
explainer := newNotFoundExplainer(ch, "shop", "main")
notFound := &live.NotFoundError{Organization: "acme", Database: "shop", Branch: "main"}

first := explainer.explain(context.Background(), notFound)
second := explainer.explain(context.Background(), notFound)

c.Assert(first, qt.ErrorMatches, `live connections are not enabled for branch main in database shop \(organization: acme\)`)
c.Assert(second, qt.Equals, first)
c.Assert(lookups, qt.Equals, 1)
}

func TestNotFoundExplainerPassesOtherErrorsThrough(t *testing.T) {
c := qt.New(t)
other := errors.New("list connections: rate limited, please retry in a moment")

got := newNotFoundExplainer(&cmdutil.Helper{}, "shop", "main").explain(context.Background(), other)

c.Assert(got, qt.Equals, other)
}
31 changes: 24 additions & 7 deletions internal/cmd/branch/connections/engine_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,43 @@ import (
ps "github.com/planetscale/cli/internal/planetscale"
)

var errNekiUnsupported = errors.New("connections is not supported for Neki databases")
var (
errVitessTargetOnly = errors.New("--keyspace/--shard are only supported for Vitess databases")
errPostgresFilterOnly = errors.New("--instance/--role are only supported for Postgres databases")
errNekiReadOnly = errors.New("connections are read-only for Neki databases")
)

// ValidateEngineFlags rejects flags that only apply to another database engine.
func ValidateEngineFlags(engine ps.DatabaseEngine, filter ConnectionFilter, target ConnectionTarget) error {
return validateEngineFlags(engine, filter.connectionFilter(), target)
}

func validateEngineFlags(engine ps.DatabaseEngine, filter connectionFilter, target ConnectionTarget) error {
if engine == ps.DatabaseEngineNeki {
return errNekiUnsupported
}
vitessTarget := target.Keyspace != "" || target.Shard != ""
switch engine {
case ps.DatabaseEnginePostgres:
if target.Keyspace != "" || target.Shard != "" {
return errors.New("--keyspace/--shard are only supported for Vitess databases")
if vitessTarget {
return errVitessTargetOnly
}
case ps.DatabaseEngineMySQL:
if filter.active() {
return errors.New("--instance/--role are only supported for Postgres databases")
return errPostgresFilterOnly
}
case ps.DatabaseEngineNeki:
if vitessTarget {
return errVitessTargetOnly
}
if filter.active() {
return errPostgresFilterOnly
}
}
return nil
}

// ValidateActionEngine rejects kill commands for engines whose connections are read-only.
func ValidateActionEngine(engine ps.DatabaseEngine) error {
if engine == ps.DatabaseEngineNeki {
return errNekiReadOnly
}
return nil
}
36 changes: 32 additions & 4 deletions internal/cmd/branch/connections/engine_flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,38 @@ import (
ps "github.com/planetscale/cli/internal/planetscale"
)

func TestValidateEngineFlagsRejectsNeki(t *testing.T) {
c := qt.New(t)
func TestValidateEngineFlagsForNeki(t *testing.T) {
tests := []struct {
name string
filter ConnectionFilter
target ConnectionTarget
wantErr string
}{
{name: "no flags"},
{name: "vitess target", target: ConnectionTarget{Keyspace: "commerce"}, wantErr: "--keyspace/--shard are only supported for Vitess databases"},
{name: "instance filter", filter: ConnectionFilter{Instance: "router-a"}, wantErr: "--instance/--role are only supported for Postgres databases"},
{name: "role filter", filter: ConnectionFilter{Role: "primary"}, wantErr: "--instance/--role are only supported for Postgres databases"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := qt.New(t)

err := ValidateEngineFlags(ps.DatabaseEngineNeki, tt.filter, tt.target)

err := ValidateEngineFlags(ps.DatabaseEngineNeki, ConnectionFilter{}, ConnectionTarget{})
if tt.wantErr == "" {
c.Assert(err, qt.IsNil)
return
}
c.Assert(err, qt.ErrorMatches, tt.wantErr)
})
}
}

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

c.Assert(err, qt.ErrorMatches, "connections is not supported for Neki databases")
c.Assert(ValidateActionEngine(ps.DatabaseEngineNeki), qt.ErrorMatches, "connections are read-only for Neki databases")
c.Assert(ValidateActionEngine(ps.DatabaseEnginePostgres), qt.IsNil)
c.Assert(ValidateActionEngine(ps.DatabaseEngineMySQL), qt.IsNil)
}
40 changes: 40 additions & 0 deletions internal/cmd/branch/connections/replay_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,46 @@ func TestTopCmdReplayUsesVitessViewForMySQLTrace(t *testing.T) {
c.Assert(view, qt.Not(qt.Contains), "WAIT")
}

func TestTopCmdReplayUsesNekiViewForNekiTrace(t *testing.T) {
c := qt.New(t)
restoreTTY := setPrinterTTY(t, true)
defer restoreTTY()

var capturedModel tea.Model
restoreProgram := setRunTeaProgram(t, func(model tea.Model, options ...tea.ProgramOption) error {
capturedModel = model
return nil
})
defer restoreProgram()

path := filepath.Join(t.TempDir(), "neki.jsonl")
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
c.Assert(err, qt.IsNil)
list := live.NewConnectionList(time.Date(2026, 10, 16, 12, 0, 5, 0, time.UTC), []live.Connection{{
PID: 7,
Instance: "router-a",
RouterName: "router-a",
State: "active",
QueryText: "SELECT 1",
}}, live.SortByTransactionStart)
list.DatabaseKind = live.DatabaseKindNeki
list.Warnings = []string{"router router-b unreachable"}
c.Assert(history.NewCaptureWriter(file).Write(history.NewCapture(list)), qt.IsNil)
c.Assert(file.Close(), qt.IsNil)

cmd := testTopCmd(&cmdutil.Helper{Config: &config.Config{}})
cmd.SetArgs([]string{"--replay", path})

c.Assert(cmd.Execute(), qt.IsNil)
sized, _ := capturedModel.(tui.Model).Update(tea.WindowSizeMsg{Width: 160, Height: 24})
view := sized.(tui.Model).View()

c.Assert(view, qt.Contains, "ROUTER")
c.Assert(view, qt.Contains, "router-a")
c.Assert(view, qt.Contains, "incomplete results: router router-b unreachable")
c.Assert(view, qt.Not(qt.Contains), "BLOCK")
}

func TestReplayHeaderShowsTraceTarget(t *testing.T) {
c := qt.New(t)
restoreTTY := setPrinterTTY(t, true)
Expand Down
35 changes: 30 additions & 5 deletions internal/cmd/branch/connections/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func runList(ctx context.Context, ch *cmdutil.Helper, database, branch string, f
return err
}

list, err := filteredLister{client: client, filter: filter}.List(ctx, live.SortByTransactionStart)
list, err := filteredLister{client: client, filter: filter, notFound: newNotFoundExplainer(ch, database, branch)}.List(ctx, live.SortByTransactionStart)
if err != nil {
return live.UserFacingError(err, "view")
}
Expand Down Expand Up @@ -76,6 +76,7 @@ type printableList struct {
Topology *ListTopology `json:"topology,omitempty"`
Instances []printableInstance `json:"instances"`
Connections []printableConnection `json:"connections"`
Warnings []string `json:"warnings,omitempty"`
}

type printableInstance struct {
Expand All @@ -101,6 +102,13 @@ type printableConnection struct {
QueryID *string `csv:"query_id" header:"query_id" json:"query_id"`
TransactionID *string `csv:"transaction_id" header:"transaction_id" json:"transaction_id"`
ConnectionID *string `csv:"connection_id" header:"connection_id" json:"connection_id"`

RouterName string `csv:"-" json:"router_name,omitempty"`
RouterCell string `csv:"-" json:"router_cell,omitempty"`
RouterUID string `csv:"-" json:"router_uid,omitempty"`
AvailabilityZone string `csv:"-" json:"availability_zone,omitempty"`
BackendStart *time.Time `csv:"-" json:"backend_start,omitempty"`
SidecarBackends []live.SidecarBackend `csv:"-" json:"sidecar_backends,omitempty"`
}

func toPrintableList(list live.ConnectionList, topology ListTopology) printableList {
Expand All @@ -109,6 +117,7 @@ func toPrintableList(list live.ConnectionList, topology ListTopology) printableL
CapturedAt: list.CapturedAt,
Instances: toPrintableInstances(list.Instances),
Connections: toPrintableConnections(list),
Warnings: list.Warnings,
}
if !topology.isEmpty() {
out.Topology = &topology
Expand Down Expand Up @@ -150,7 +159,7 @@ func (p printableList) hasDatabaseName() bool {
func toPrintableConnections(list live.ConnectionList) []printableConnection {
connections := make([]printableConnection, 0, len(list.Connections))
for _, conn := range list.Connections {
connections = append(connections, printableConnection{
printable := printableConnection{
PID: conn.PID,
Instance: conn.Instance,
InstanceRole: conn.InstanceRole,
Expand All @@ -167,7 +176,16 @@ func toPrintableConnections(list live.ConnectionList) []printableConnection {
QueryID: conn.QueryID,
TransactionID: conn.TransactionID,
ConnectionID: conn.ConnectionID,
})
}
if list.DatabaseKind == live.DatabaseKindNeki {
printable.RouterName = conn.RouterName
printable.RouterCell = conn.RouterCell
printable.RouterUID = conn.RouterUID
printable.AvailabilityZone = conn.AvailabilityZone
printable.BackendStart = conn.BackendStart
printable.SidecarBackends = conn.SidecarBackends
}
connections = append(connections, printable)
}
return connections
}
Expand All @@ -185,6 +203,7 @@ func printableBlockedBy(blockedBy []int) []int {
func printHumanConnectionList(out io.Writer, list live.ConnectionList, topology ListTopology) {
fmt.Fprintf(out, "captured_at: %s\n", list.CapturedAt.Format(time.RFC3339))
vitess := list.DatabaseKind == live.DatabaseKindMySQL || !topology.isEmpty()
neki := list.DatabaseKind == live.DatabaseKindNeki
if !topology.isEmpty() {
fmt.Fprintln(out, "topology:")
fmt.Fprintf(out, " keyspace: %s\n", topology.Keyspace)
Expand All @@ -194,6 +213,9 @@ func printHumanConnectionList(out io.Writer, list live.ConnectionList, topology
if warning := unreachableInstanceWarning(list.Instances); warning != "" {
fmt.Fprintln(out, warning)
}
for _, warning := range list.Warnings {
fmt.Fprintln(out, "warning: incomplete results: "+live.SanitizeDisplayText(warning))
}

if len(list.Connections) == 0 {
fmt.Fprintln(out, "No live connections found.")
Expand All @@ -207,13 +229,16 @@ func printHumanConnectionList(out io.Writer, list live.ConnectionList, topology

fmt.Fprintf(out, "*************************** %d. row ***************************\n", i+1)
fields := conn.HumanFields()
if vitess {
switch {
case vitess:
fields = vitessHumanFields(conn)
case neki:
fields = conn.NekiHumanFields()
}
for _, field := range fields {
writeHumanField(out, field[0], field[1])
}
if conn.DatabaseName != "" {
if conn.DatabaseName != "" && !neki {
writeHumanField(out, "database", conn.DatabaseName)
}
fmt.Fprintln(out, "query:")
Expand Down
Loading
Loading