From 6248525289fc3c7c6b38ec2a7a194793730d2860 Mon Sep 17 00:00:00 2001 From: Ryan Leung Date: Thu, 2 Apr 2026 15:19:52 +0800 Subject: [PATCH 1/4] pd-ctl: extend region keyspace lookup to table ranges Reuse the existing region keyspace command instead of adding a parallel entry point for table-scoped lookups. Add request-shape tests so the keyspace and table range queries stay aligned with the current CLI behavior. Signed-off-by: Ryan Leung --- tools/pd-ctl/pdctl/command/region_command.go | 47 +++- .../pdctl/command/region_command_test.go | 205 ++++++++++++++++++ 2 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 tools/pd-ctl/pdctl/command/region_command_test.go diff --git a/tools/pd-ctl/pdctl/command/region_command.go b/tools/pd-ctl/pdctl/command/region_command.go index 5d8e89309ee..24964969685 100644 --- a/tools/pd-ctl/pdctl/command/region_command.go +++ b/tools/pd-ctl/pdctl/command/region_command.go @@ -17,6 +17,7 @@ package command import ( "bytes" "context" + "encoding/binary" "encoding/hex" "encoding/json" "fmt" @@ -535,7 +536,7 @@ func NewRegionWithKeyspaceCommand() *cobra.Command { Short: "show region information of the given keyspace", } r.AddCommand(&cobra.Command{ - Use: "id ", + Use: "id [table-id ] []", Short: "show region information for the given keyspace id", Run: showRegionWithKeyspaceCommandFunc, }) @@ -543,20 +544,47 @@ func NewRegionWithKeyspaceCommand() *cobra.Command { } func showRegionWithKeyspaceCommandFunc(cmd *cobra.Command, args []string) { - if len(args) < 1 || len(args) > 2 { + if len(args) < 1 || len(args) > 4 { cmd.Println(cmd.UsageString()) return } - keyspaceID := args[0] - prefix := regionsKeyspacePrefix + "/id/" + keyspaceID + if _, err := strconv.ParseUint(args[0], 10, 32); err != nil { + cmd.Println("keyspace_id should be a number") + return + } + + prefix := regionsKeyspacePrefix + "/id/" + args[0] if len(args) == 2 { if _, err := strconv.Atoi(args[1]); err != nil { cmd.Println("limit should be a number") return } prefix += "?limit=" + args[1] + } else if len(args) >= 3 { + if args[1] != "table-id" { + cmd.Println("the second argument should be table-id") + return + } + tableID, err := strconv.ParseInt(args[2], 10, 64) + if err != nil { + cmd.Println("table-id should be a number") + return + } + query := make(url.Values) + startKey, endKey := makeTableRangeInKeyspace(args[0], tableID) + query.Set("key", url.QueryEscape(string(startKey))) + query.Set("end_key", url.QueryEscape(string(endKey))) + if len(args) == 4 { + if _, err := strconv.Atoi(args[3]); err != nil { + cmd.Println("limit should be a number") + return + } + query.Set("limit", args[3]) + } + prefix = regionsKeyPrefix + "?" + query.Encode() } + r, err := doRequest(cmd, prefix, http.MethodGet, http.Header{}) if err != nil { cmd.Printf("Failed to get regions with the given keyspace: %s\n", err) @@ -565,6 +593,17 @@ func showRegionWithKeyspaceCommandFunc(cmd *cobra.Command, args []string) { cmd.Println(r) } +func makeTableRangeInKeyspace(keyspaceID string, tableID int64) ([]byte, []byte) { + keyspaceIDUint64, _ := strconv.ParseUint(keyspaceID, 10, 32) + keyspaceIDBytes := make([]byte, 4) + binary.BigEndian.PutUint32(keyspaceIDBytes, uint32(keyspaceIDUint64)) + + keyPrefix := append([]byte{'x'}, keyspaceIDBytes[1:]...) + startKey := codec.EncodeBytes(nil, append(keyPrefix, append([]byte{'t'}, codec.EncodeInt(nil, tableID)...)...)) + endKey := codec.EncodeBytes(nil, append(keyPrefix, append([]byte{'t'}, codec.EncodeInt(nil, tableID+1)...)...)) + return startKey, endKey +} + const ( rangeHolesLongDesc = `There are some cases that the region range is not continuous, for example, the region doesn't send the heartbeat to PD after a splitting. This command will output all empty ranges without any region info.` diff --git a/tools/pd-ctl/pdctl/command/region_command_test.go b/tools/pd-ctl/pdctl/command/region_command_test.go new file mode 100644 index 00000000000..34dfcbae2d9 --- /dev/null +++ b/tools/pd-ctl/pdctl/command/region_command_test.go @@ -0,0 +1,205 @@ +// Copyright 2025 TiKV Project Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package command + +import ( + "bytes" + "encoding/binary" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tikv/pd/tools/pd-ctl/helper/tidb/codec" +) + +type captureRegionRoundTripper struct { + path string + rawQuery string + body string + err error +} + +func (m *captureRegionRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + m.path = req.URL.Path + m.rawQuery = req.URL.RawQuery + if m.err != nil { + return nil, m.err + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(m.body)), + }, nil +} + +func TestRegionKeyspaceIDPath(t *testing.T) { + re := require.New(t) + rt := &captureRegionRoundTripper{body: `{"ok":true}`} + oldClient := dialClient + dialClient = &http.Client{Transport: rt} + defer func() { dialClient = oldClient }() + + cmd := NewRegionWithKeyspaceCommand() + cmd.PersistentFlags().String("pd", "http://mock-pd:2379", "") + cmd.SetArgs([]string{"id", "1"}) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + re.NoError(cmd.Execute()) + re.Equal("/pd/api/v1/regions/keyspace/id/1", rt.path) + re.Empty(rt.rawQuery) + re.Contains(out.String(), `{"ok":true}`) +} + +func TestRegionKeyspaceIDTableIDPath(t *testing.T) { + re := require.New(t) + rt := &captureRegionRoundTripper{body: `{"ok":true}`} + oldClient := dialClient + dialClient = &http.Client{Transport: rt} + defer func() { dialClient = oldClient }() + + startKey, endKey := expectedTableRangeInKeyspace(1, 100) + query := make(url.Values) + query.Set("key", url.QueryEscape(string(startKey))) + query.Set("end_key", url.QueryEscape(string(endKey))) + + cmd := NewRegionWithKeyspaceCommand() + cmd.PersistentFlags().String("pd", "http://mock-pd:2379", "") + cmd.SetArgs([]string{"id", "1", "table-id", "100"}) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + re.NoError(cmd.Execute()) + re.Equal("/pd/api/v1/regions/key", rt.path) + re.Equal(query.Encode(), rt.rawQuery) + re.Contains(out.String(), `{"ok":true}`) +} + +func TestRegionKeyspaceIDPathWithLimit(t *testing.T) { + re := require.New(t) + rt := &captureRegionRoundTripper{body: `{"ok":true}`} + oldClient := dialClient + dialClient = &http.Client{Transport: rt} + defer func() { dialClient = oldClient }() + + cmd := NewRegionWithKeyspaceCommand() + cmd.PersistentFlags().String("pd", "http://mock-pd:2379", "") + cmd.SetArgs([]string{"id", "1", "16"}) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + re.NoError(cmd.Execute()) + re.Equal("/pd/api/v1/regions/keyspace/id/1", rt.path) + re.Equal("limit=16", rt.rawQuery) + re.Contains(out.String(), `{"ok":true}`) +} + +func TestRegionKeyspaceIDTableIDPathWithLimit(t *testing.T) { + re := require.New(t) + rt := &captureRegionRoundTripper{body: `{"ok":true}`} + oldClient := dialClient + dialClient = &http.Client{Transport: rt} + defer func() { dialClient = oldClient }() + + startKey, endKey := expectedTableRangeInKeyspace(1, 100) + query := make(url.Values) + query.Set("key", url.QueryEscape(string(startKey))) + query.Set("end_key", url.QueryEscape(string(endKey))) + query.Set("limit", "16") + + cmd := NewRegionWithKeyspaceCommand() + cmd.PersistentFlags().String("pd", "http://mock-pd:2379", "") + cmd.SetArgs([]string{"id", "1", "table-id", "100", "16"}) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + re.NoError(cmd.Execute()) + re.Equal("/pd/api/v1/regions/key", rt.path) + re.Equal(query.Encode(), rt.rawQuery) + re.Contains(out.String(), `{"ok":true}`) +} + +func TestRegionKeyspaceIDInvalidKeyspaceID(t *testing.T) { + re := require.New(t) + + cmd := NewRegionWithKeyspaceCommand() + cmd.PersistentFlags().String("pd", "http://mock-pd:2379", "") + cmd.SetArgs([]string{"id", "invalid"}) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + re.NoError(cmd.Execute()) + re.Contains(out.String(), "keyspace_id should be a number") +} + +func TestRegionKeyspaceIDInvalidTableID(t *testing.T) { + re := require.New(t) + + cmd := NewRegionWithKeyspaceCommand() + cmd.PersistentFlags().String("pd", "http://mock-pd:2379", "") + cmd.SetArgs([]string{"id", "1", "table-id", "invalid"}) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + re.NoError(cmd.Execute()) + re.Contains(out.String(), "table-id should be a number") +} + +func TestRegionKeyspaceIDInvalidLimit(t *testing.T) { + re := require.New(t) + + cmd := NewRegionWithKeyspaceCommand() + cmd.PersistentFlags().String("pd", "http://mock-pd:2379", "") + cmd.SetArgs([]string{"id", "1", "table-id", "100", "invalid"}) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + re.NoError(cmd.Execute()) + re.Contains(out.String(), "limit should be a number") +} + +func TestRegionKeyspaceIDWrongTableIDLiteral(t *testing.T) { + re := require.New(t) + + cmd := NewRegionWithKeyspaceCommand() + cmd.PersistentFlags().String("pd", "http://mock-pd:2379", "") + cmd.SetArgs([]string{"id", "1", "table", "100"}) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + re.NoError(cmd.Execute()) + re.Contains(out.String(), "the second argument should be table-id") +} + +func expectedTableRangeInKeyspace(keyspaceID uint32, tableID int64) ([]byte, []byte) { + keyspaceIDBytes := make([]byte, 4) + binary.BigEndian.PutUint32(keyspaceIDBytes, keyspaceID) + + keyPrefix := append([]byte{'x'}, keyspaceIDBytes[1:]...) + startKey := codec.EncodeBytes(nil, append(keyPrefix, append([]byte{'t'}, codec.EncodeInt(nil, tableID)...)...)) + endKey := codec.EncodeBytes(nil, append(keyPrefix, append([]byte{'t'}, codec.EncodeInt(nil, tableID+1)...)...)) + return startKey, endKey +} From 9a39eb7531bae23458ec1f66a7efce54195daef4 Mon Sep 17 00:00:00 2001 From: Ryan Leung Date: Tue, 7 Apr 2026 14:57:32 +0800 Subject: [PATCH 2/4] pd-ctl: fix region keyspace table query escaping Keep table range lookups aligned with url.Values encoding and reject keyspace IDs outside the supported 24-bit range. Update the command tests to match the request shape and cover the out-of-range keyspace case. Signed-off-by: Ryan Leung --- tools/pd-ctl/pdctl/command/region_command.go | 21 ++++++++++++------ .../pdctl/command/region_command_test.go | 22 +++++++++++++++---- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/tools/pd-ctl/pdctl/command/region_command.go b/tools/pd-ctl/pdctl/command/region_command.go index 24964969685..376fe68e45f 100644 --- a/tools/pd-ctl/pdctl/command/region_command.go +++ b/tools/pd-ctl/pdctl/command/region_command.go @@ -35,6 +35,7 @@ import ( "github.com/pingcap/errors" "github.com/pingcap/failpoint" + "github.com/tikv/pd/client/constants" "github.com/tikv/pd/client/clients/router" pd "github.com/tikv/pd/client/http" @@ -549,10 +550,17 @@ func showRegionWithKeyspaceCommandFunc(cmd *cobra.Command, args []string) { return } - if _, err := strconv.ParseUint(args[0], 10, 32); err != nil { + keyspaceIDUint64, err := strconv.ParseUint(args[0], 10, 32) + if err != nil { cmd.Println("keyspace_id should be a number") return } + keyspaceID := uint32(keyspaceIDUint64) + if keyspaceID < constants.DefaultKeyspaceID || keyspaceID > constants.MaxKeyspaceID { + cmd.Printf("invalid keyspace id %d. It must be in the range of [%d, %d]\n", + keyspaceID, constants.DefaultKeyspaceID, constants.MaxKeyspaceID) + return + } prefix := regionsKeyspacePrefix + "/id/" + args[0] if len(args) == 2 { @@ -572,9 +580,9 @@ func showRegionWithKeyspaceCommandFunc(cmd *cobra.Command, args []string) { return } query := make(url.Values) - startKey, endKey := makeTableRangeInKeyspace(args[0], tableID) - query.Set("key", url.QueryEscape(string(startKey))) - query.Set("end_key", url.QueryEscape(string(endKey))) + startKey, endKey := makeTableRangeInKeyspace(keyspaceID, tableID) + query.Set("key", string(startKey)) + query.Set("end_key", string(endKey)) if len(args) == 4 { if _, err := strconv.Atoi(args[3]); err != nil { cmd.Println("limit should be a number") @@ -593,10 +601,9 @@ func showRegionWithKeyspaceCommandFunc(cmd *cobra.Command, args []string) { cmd.Println(r) } -func makeTableRangeInKeyspace(keyspaceID string, tableID int64) ([]byte, []byte) { - keyspaceIDUint64, _ := strconv.ParseUint(keyspaceID, 10, 32) +func makeTableRangeInKeyspace(keyspaceID uint32, tableID int64) ([]byte, []byte) { keyspaceIDBytes := make([]byte, 4) - binary.BigEndian.PutUint32(keyspaceIDBytes, uint32(keyspaceIDUint64)) + binary.BigEndian.PutUint32(keyspaceIDBytes, keyspaceID) keyPrefix := append([]byte{'x'}, keyspaceIDBytes[1:]...) startKey := codec.EncodeBytes(nil, append(keyPrefix, append([]byte{'t'}, codec.EncodeInt(nil, tableID)...)...)) diff --git a/tools/pd-ctl/pdctl/command/region_command_test.go b/tools/pd-ctl/pdctl/command/region_command_test.go index 34dfcbae2d9..6b723cdd74b 100644 --- a/tools/pd-ctl/pdctl/command/region_command_test.go +++ b/tools/pd-ctl/pdctl/command/region_command_test.go @@ -76,8 +76,8 @@ func TestRegionKeyspaceIDTableIDPath(t *testing.T) { startKey, endKey := expectedTableRangeInKeyspace(1, 100) query := make(url.Values) - query.Set("key", url.QueryEscape(string(startKey))) - query.Set("end_key", url.QueryEscape(string(endKey))) + query.Set("key", string(startKey)) + query.Set("end_key", string(endKey)) cmd := NewRegionWithKeyspaceCommand() cmd.PersistentFlags().String("pd", "http://mock-pd:2379", "") @@ -121,8 +121,8 @@ func TestRegionKeyspaceIDTableIDPathWithLimit(t *testing.T) { startKey, endKey := expectedTableRangeInKeyspace(1, 100) query := make(url.Values) - query.Set("key", url.QueryEscape(string(startKey))) - query.Set("end_key", url.QueryEscape(string(endKey))) + query.Set("key", string(startKey)) + query.Set("end_key", string(endKey)) query.Set("limit", "16") cmd := NewRegionWithKeyspaceCommand() @@ -152,6 +152,20 @@ func TestRegionKeyspaceIDInvalidKeyspaceID(t *testing.T) { re.Contains(out.String(), "keyspace_id should be a number") } +func TestRegionKeyspaceIDOutOfRangeKeyspaceID(t *testing.T) { + re := require.New(t) + + cmd := NewRegionWithKeyspaceCommand() + cmd.PersistentFlags().String("pd", "http://mock-pd:2379", "") + cmd.SetArgs([]string{"id", "16777216"}) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + + re.NoError(cmd.Execute()) + re.Contains(out.String(), "invalid keyspace id 16777216. It must be in the range of [0, 16777215]") +} + func TestRegionKeyspaceIDInvalidTableID(t *testing.T) { re := require.New(t) From a54a12278015d44c5d3e898f57c9d4bb4d0ae0fe Mon Sep 17 00:00:00 2001 From: Ryan Leung Date: Tue, 7 Apr 2026 16:47:07 +0800 Subject: [PATCH 3/4] pd-ctl: fix region keyspace static checks Align the new region keyspace helpers with the repository static checks by fixing import ordering and using named return values. This keeps the CI-only cleanup together with the helper logic and its direct test coverage. Signed-off-by: Ryan Leung --- tools/pd-ctl/pdctl/command/region_command.go | 8 ++++---- tools/pd-ctl/pdctl/command/region_command_test.go | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tools/pd-ctl/pdctl/command/region_command.go b/tools/pd-ctl/pdctl/command/region_command.go index 376fe68e45f..29cebd278fc 100644 --- a/tools/pd-ctl/pdctl/command/region_command.go +++ b/tools/pd-ctl/pdctl/command/region_command.go @@ -35,9 +35,9 @@ import ( "github.com/pingcap/errors" "github.com/pingcap/failpoint" - "github.com/tikv/pd/client/constants" "github.com/tikv/pd/client/clients/router" + "github.com/tikv/pd/client/constants" pd "github.com/tikv/pd/client/http" "github.com/tikv/pd/pkg/utils/typeutil" "github.com/tikv/pd/tools/pd-ctl/helper/mok" @@ -601,13 +601,13 @@ func showRegionWithKeyspaceCommandFunc(cmd *cobra.Command, args []string) { cmd.Println(r) } -func makeTableRangeInKeyspace(keyspaceID uint32, tableID int64) ([]byte, []byte) { +func makeTableRangeInKeyspace(keyspaceID uint32, tableID int64) (startKey, endKey []byte) { keyspaceIDBytes := make([]byte, 4) binary.BigEndian.PutUint32(keyspaceIDBytes, keyspaceID) keyPrefix := append([]byte{'x'}, keyspaceIDBytes[1:]...) - startKey := codec.EncodeBytes(nil, append(keyPrefix, append([]byte{'t'}, codec.EncodeInt(nil, tableID)...)...)) - endKey := codec.EncodeBytes(nil, append(keyPrefix, append([]byte{'t'}, codec.EncodeInt(nil, tableID+1)...)...)) + startKey = codec.EncodeBytes(nil, append(keyPrefix, append([]byte{'t'}, codec.EncodeInt(nil, tableID)...)...)) + endKey = codec.EncodeBytes(nil, append(keyPrefix, append([]byte{'t'}, codec.EncodeInt(nil, tableID+1)...)...)) return startKey, endKey } diff --git a/tools/pd-ctl/pdctl/command/region_command_test.go b/tools/pd-ctl/pdctl/command/region_command_test.go index 6b723cdd74b..002b8d4b4a0 100644 --- a/tools/pd-ctl/pdctl/command/region_command_test.go +++ b/tools/pd-ctl/pdctl/command/region_command_test.go @@ -208,12 +208,12 @@ func TestRegionKeyspaceIDWrongTableIDLiteral(t *testing.T) { re.Contains(out.String(), "the second argument should be table-id") } -func expectedTableRangeInKeyspace(keyspaceID uint32, tableID int64) ([]byte, []byte) { +func expectedTableRangeInKeyspace(keyspaceID uint32, tableID int64) (startKey, endKey []byte) { keyspaceIDBytes := make([]byte, 4) binary.BigEndian.PutUint32(keyspaceIDBytes, keyspaceID) keyPrefix := append([]byte{'x'}, keyspaceIDBytes[1:]...) - startKey := codec.EncodeBytes(nil, append(keyPrefix, append([]byte{'t'}, codec.EncodeInt(nil, tableID)...)...)) - endKey := codec.EncodeBytes(nil, append(keyPrefix, append([]byte{'t'}, codec.EncodeInt(nil, tableID+1)...)...)) + startKey = codec.EncodeBytes(nil, append(keyPrefix, append([]byte{'t'}, codec.EncodeInt(nil, tableID)...)...)) + endKey = codec.EncodeBytes(nil, append(keyPrefix, append([]byte{'t'}, codec.EncodeInt(nil, tableID+1)...)...)) return startKey, endKey } From 9d8db67385b9d52fbbe52cd43bc33e4512b81f0f Mon Sep 17 00:00:00 2001 From: Ryan Leung Date: Tue, 14 Apr 2026 17:19:41 +0800 Subject: [PATCH 4/4] pd-ctl: fix region keyspace command help text Clarify that table-id is an optional filter on keyspace lookup and align the new test file header year with the current review update. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus Signed-off-by: Ryan Leung --- tools/pd-ctl/pdctl/command/region_command.go | 2 +- tools/pd-ctl/pdctl/command/region_command_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/pd-ctl/pdctl/command/region_command.go b/tools/pd-ctl/pdctl/command/region_command.go index 29cebd278fc..7eaa5a8886d 100644 --- a/tools/pd-ctl/pdctl/command/region_command.go +++ b/tools/pd-ctl/pdctl/command/region_command.go @@ -538,7 +538,7 @@ func NewRegionWithKeyspaceCommand() *cobra.Command { } r.AddCommand(&cobra.Command{ Use: "id [table-id ] []", - Short: "show region information for the given keyspace id", + Short: "show region information for the given keyspace id, optionally filtered by table id", Run: showRegionWithKeyspaceCommandFunc, }) return r diff --git a/tools/pd-ctl/pdctl/command/region_command_test.go b/tools/pd-ctl/pdctl/command/region_command_test.go index 002b8d4b4a0..e1557ec2213 100644 --- a/tools/pd-ctl/pdctl/command/region_command_test.go +++ b/tools/pd-ctl/pdctl/command/region_command_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 TiKV Project Authors. +// Copyright 2026 TiKV Project Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License.