diff --git a/pkg/codec/codec.go b/pkg/codec/codec.go index 481a69acd09..27086b986de 100644 --- a/pkg/codec/codec.go +++ b/pkg/codec/codec.go @@ -33,42 +33,103 @@ const ( encGroupSize = 8 encMarker = byte(0xFF) encPad = byte(0x0) + + // RawKeyspaceModePrefix is the raw keyspace prefix mode byte. + RawKeyspaceModePrefix = byte('r') + // TxnKeyspaceModePrefix is the txn keyspace prefix mode byte. + TxnKeyspaceModePrefix = byte('x') + // KeyspacePrefixLen is the raw keyspace prefix length before memcomparable encoding. + KeyspacePrefixLen = 4 ) // Key represents high-level Key type. type Key []byte -// TableID returns the table ID of the key, if the key is not table key, returns 0. -func (k Key) TableID() int64 { +// MakeKeyspacePrefix constructs the raw keyspace prefix for the given mode and keyspace ID. +// Keyspace keys encode the lower 24 bits of the keyspace ID after the mode byte. +func MakeKeyspacePrefix(mode byte, id uint32) []byte { + prefix := make([]byte, KeyspacePrefixLen) + binary.BigEndian.PutUint32(prefix, id) + prefix[0] = mode + return prefix +} + +// ParseKeyspacePrefix parses a raw keyspace prefix from key. +// It returns false for keys that do not start with a known keyspace mode byte. +func ParseKeyspacePrefix(key []byte) (mode byte, id uint32, ok bool) { + if len(key) < KeyspacePrefixLen { + return 0, 0, false + } + mode = key[0] + if mode != RawKeyspaceModePrefix && mode != TxnKeyspaceModePrefix { + return 0, 0, false + } + idBytes := [KeyspacePrefixLen]byte{0, key[1], key[2], key[3]} + id = binary.BigEndian.Uint32(idBytes[:]) + return mode, id, true +} + +// unwrapKeyspace strips the API v2 txn keyspace prefix (mode byte + 24-bit id) +// when the remainder is a TiDB meta/table key. TiDB data only lives under the +// txn ('x') mode; raw-mode payloads are arbitrary user bytes, so raw keys and +// keys that only happen to start with 'x' are left unchanged with hasKeyspace +// false. +func unwrapKeyspace(key []byte) (payload []byte, keyspaceID uint32, hasKeyspace bool) { + mode, keyspaceID, ok := ParseKeyspacePrefix(key) + if !ok || mode != TxnKeyspaceModePrefix { + return key, 0, false + } + rest := key[KeyspacePrefixLen:] + if !bytes.HasPrefix(rest, tablePrefix) && !bytes.HasPrefix(rest, metaPrefix) { + return key, 0, false + } + return rest, keyspaceID, true +} + +// TableIdentity identifies the logical table a key belongs to. HasKeyspace is +// false for classic TiDB keys, distinguishing them from keyspace 0. TableID is +// 0 when the key is not a table key (including meta keys), so all non-table +// keys of one keyspace share a single identity. Two table keys belong to the +// same logical table iff their TableIdentity values are equal. +type TableIdentity struct { + KeyspaceID uint32 + TableID int64 + HasKeyspace bool +} + +// TableIdentity returns the keyspace-qualified table identity of an encoded key. +func (k Key) TableIdentity() TableIdentity { _, key, err := DecodeBytes(k) if err != nil { - // should never happen - return 0 + // should never happen for region boundary keys produced by TiKV + return TableIdentity{} } - if !bytes.HasPrefix(key, tablePrefix) { - return 0 + key, keyspaceID, hasKeyspace := unwrapKeyspace(key) + identity := TableIdentity{KeyspaceID: keyspaceID, HasKeyspace: hasKeyspace} + if bytes.HasPrefix(key, tablePrefix) { + // A truncated table key fails to decode and keeps TableID 0, i.e. it + // is treated as a non-table key, matching the historical semantics. + _, identity.TableID, _ = DecodeInt(key[len(tablePrefix):]) } - key = key[len(tablePrefix):] - - _, tableID, _ := DecodeInt(key) - return tableID + return identity } // MetaOrTable checks if the key is a meta key or table key. // If the key is a meta key, it returns true and 0. // If the key is a table key, it returns false and table ID. // Otherwise, it returns false and 0. +// It supports both classic TiDB keys and API v2 keyspace-prefixed keys. func (k Key) MetaOrTable() (bool, int64) { _, key, err := DecodeBytes(k) if err != nil { return false, 0 } + key, _, _ = unwrapKeyspace(key) if bytes.HasPrefix(key, metaPrefix) { return true, 0 } if bytes.HasPrefix(key, tablePrefix) { - key = key[len(tablePrefix):] - _, tableID, _ := DecodeInt(key) + _, tableID, _ := DecodeInt(key[len(tablePrefix):]) return false, tableID } return false, 0 diff --git a/pkg/codec/codec_test.go b/pkg/codec/codec_test.go index 2121cabf2b8..6107561b68f 100644 --- a/pkg/codec/codec_test.go +++ b/pkg/codec/codec_test.go @@ -38,17 +38,107 @@ func TestDecodeBytes(t *testing.T) { func TestTableID(t *testing.T) { re := require.New(t) key := EncodeBytes([]byte("t\x80\x00\x00\x00\x00\x00\x00\xff")) - re.Equal(int64(0xff), key.TableID()) + re.Equal(int64(0xff), key.TableIdentity().TableID) key = EncodeBytes([]byte("t\x80\x00\x00\x00\x00\x00\x00\xff_i\x01\x02")) - re.Equal(int64(0xff), key.TableID()) + re.Equal(int64(0xff), key.TableIdentity().TableID) key = []byte("t\x80\x00\x00\x00\x00\x00\x00\xff") - re.Equal(int64(0), key.TableID()) + re.Equal(int64(0), key.TableIdentity().TableID) key = EncodeBytes([]byte("T\x00\x00\x00\x00\x00\x00\x00\xff")) - re.Equal(int64(0), key.TableID()) + re.Equal(int64(0), key.TableIdentity().TableID) key = EncodeBytes([]byte("t\x80\x00\x00\x00\x00\x00\xff")) - re.Equal(int64(0), key.TableID()) + re.Equal(int64(0), key.TableIdentity().TableID) +} + +func TestTableIDWithKeyspacePrefix(t *testing.T) { + re := require.New(t) + tableID := int64(100) + otherTableID := int64(200) + keyspaceID := uint32(42) + + classic := EncodeBytes(GenerateTableKey(tableID)) + re.Equal(TableIdentity{TableID: tableID}, classic.TableIdentity()) + + prefix := MakeKeyspacePrefix(TxnKeyspaceModePrefix, keyspaceID) + identity := TableIdentity{KeyspaceID: keyspaceID, TableID: tableID, HasKeyspace: true} + encoded := EncodeBytes(append(append([]byte{}, prefix...), GenerateTableKey(tableID)...)) + re.Equal(identity, encoded.TableIdentity()) + + other := EncodeBytes(append(append([]byte{}, prefix...), GenerateTableKey(otherTableID)...)) + re.Equal(otherTableID, other.TableIdentity().TableID) + re.NotEqual(encoded.TableIdentity(), other.TableIdentity()) + + // Same table: record and index keys must still resolve to the same identity. + record := EncodeBytes(append(append([]byte{}, prefix...), GenerateRowKey(tableID, 1)...)) + indexKey := append(GenerateTableKey(tableID), '_', 'i') + indexKey = EncodeInt(indexKey, 7) + index := EncodeBytes(append(append([]byte{}, prefix...), indexKey...)) + re.Equal(identity, record.TableIdentity()) + re.Equal(identity, index.TableIdentity()) + + // Same numeric table id under different keyspaces is a different identity. + ks1 := EncodeBytes(append(MakeKeyspacePrefix(TxnKeyspaceModePrefix, 1), GenerateTableKey(tableID)...)) + ks2 := EncodeBytes(append(MakeKeyspacePrefix(TxnKeyspaceModePrefix, 2), GenerateTableKey(tableID)...)) + re.Equal(ks1.TableIdentity().TableID, ks2.TableIdentity().TableID) + re.NotEqual(ks1.TableIdentity(), ks2.TableIdentity()) + + // A raw key that only happens to start with the txn mode byte but is not + // followed by a TiDB table/meta payload must not be treated as a table key. + ambiguous := EncodeBytes([]byte{'x', 0x00, 0x00, 0x2a, 'u', 's', 'e', 'r'}) + re.Equal(TableIdentity{}, ambiguous.TableIdentity()) + + // TiDB data only lives under the txn mode: a raw-mode keyspace key whose + // payload happens to look like a table key gets no table identity. + rawMode := EncodeBytes(append(MakeKeyspacePrefix(RawKeyspaceModePrefix, keyspaceID), GenerateTableKey(tableID)...)) + re.Equal(TableIdentity{}, rawMode.TableIdentity()) +} + +func TestMetaOrTableWithKeyspacePrefix(t *testing.T) { + re := require.New(t) + tableID := int64(55) + keyspaceID := uint32(7) + prefix := MakeKeyspacePrefix(TxnKeyspaceModePrefix, keyspaceID) + + isMeta, id := EncodeBytes(append(append([]byte{}, prefix...), metaPrefix...)).MetaOrTable() + re.True(isMeta) + re.Equal(int64(0), id) + + isMeta, id = EncodeBytes(append(append([]byte{}, prefix...), GenerateTableKey(tableID)...)).MetaOrTable() + re.False(isMeta) + re.Equal(tableID, id) + + isMeta, id = EncodeBytes([]byte("hello")).MetaOrTable() + re.False(isMeta) + re.Equal(int64(0), id) +} + +func TestMakeKeyspacePrefix(t *testing.T) { + re := require.New(t) + re.Equal([]byte{'r', 0x01, 0x02, 0x03}, MakeKeyspacePrefix(RawKeyspaceModePrefix, 0x010203)) + // Only the lower 24 bits of the keyspace ID are encoded. + re.Equal([]byte{'x', 0xff, 0xff, 0xff}, MakeKeyspacePrefix(TxnKeyspaceModePrefix, 0xffffff)) +} + +func TestParseKeyspacePrefix(t *testing.T) { + re := require.New(t) + + mode, id, ok := ParseKeyspacePrefix([]byte{'r', 0x01, 0x02, 0x03}) + re.True(ok) + re.Equal(RawKeyspaceModePrefix, mode) + re.Equal(uint32(0x010203), id) + + mode, id, ok = ParseKeyspacePrefix([]byte{'x', 0xff, 0xff, 0xff, 't'}) + re.True(ok) + re.Equal(TxnKeyspaceModePrefix, mode) + re.Equal(uint32(0xffffff), id) + + // Too short. + _, _, ok = ParseKeyspacePrefix([]byte{'x', 0x01, 0x02}) + re.False(ok) + // Unknown mode byte. + _, _, ok = ParseKeyspacePrefix([]byte{'t', 0x01, 0x02, 0x03}) + re.False(ok) } diff --git a/pkg/keyspace/util.go b/pkg/keyspace/util.go index 7df93eefb19..c0b40a0facb 100644 --- a/pkg/keyspace/util.go +++ b/pkg/keyspace/util.go @@ -16,7 +16,6 @@ package keyspace import ( "container/heap" - "encoding/binary" "encoding/hex" "regexp" "strconv" @@ -114,15 +113,15 @@ type RegionBound struct { // MakeRegionBound constructs the correct region boundaries of the given keyspace. func MakeRegionBound(id uint32) *RegionBound { - keyspaceIDBytes := make([]byte, 4) - nextKeyspaceIDBytes := make([]byte, 4) - binary.BigEndian.PutUint32(keyspaceIDBytes, id) - binary.BigEndian.PutUint32(nextKeyspaceIDBytes, id+1) + rawLeftBound := codec.MakeKeyspacePrefix(codec.RawKeyspaceModePrefix, id) + rawRightBound := codec.MakeKeyspacePrefix(codec.RawKeyspaceModePrefix, id+1) + txnLeftBound := codec.MakeKeyspacePrefix(codec.TxnKeyspaceModePrefix, id) + txnRightBound := codec.MakeKeyspacePrefix(codec.TxnKeyspaceModePrefix, id+1) return &RegionBound{ - RawLeftBound: codec.EncodeBytes(append([]byte{'r'}, keyspaceIDBytes[1:]...)), - RawRightBound: codec.EncodeBytes(append([]byte{'r'}, nextKeyspaceIDBytes[1:]...)), - TxnLeftBound: codec.EncodeBytes(append([]byte{'x'}, keyspaceIDBytes[1:]...)), - TxnRightBound: codec.EncodeBytes(append([]byte{'x'}, nextKeyspaceIDBytes[1:]...)), + RawLeftBound: codec.EncodeBytes(rawLeftBound), + RawRightBound: codec.EncodeBytes(rawRightBound), + TxnLeftBound: codec.EncodeBytes(txnLeftBound), + TxnRightBound: codec.EncodeBytes(txnRightBound), } } diff --git a/pkg/mock/mockcluster/config.go b/pkg/mock/mockcluster/config.go index c5fdce24f31..33bf17a5afa 100644 --- a/pkg/mock/mockcluster/config.go +++ b/pkg/mock/mockcluster/config.go @@ -47,6 +47,11 @@ func (mc *Cluster) SetEnableOneWayMerge(v bool) { mc.updateScheduleConfig(func(s *sc.ScheduleConfig) { s.EnableOneWayMerge = v }) } +// SetEnableCrossTableMerge updates the EnableCrossTableMerge configuration. +func (mc *Cluster) SetEnableCrossTableMerge(v bool) { + mc.updateScheduleConfig(func(s *sc.ScheduleConfig) { s.EnableCrossTableMerge = v }) +} + // SetMaxSnapshotCount updates the MaxSnapshotCount configuration. func (mc *Cluster) SetMaxSnapshotCount(v int) { mc.updateScheduleConfig(func(s *sc.ScheduleConfig) { s.MaxSnapshotCount = uint64(v) }) diff --git a/pkg/schedule/checker/merge_checker.go b/pkg/schedule/checker/merge_checker.go index 571ee134da0..80d607ac6c2 100644 --- a/pkg/schedule/checker/merge_checker.go +++ b/pkg/schedule/checker/merge_checker.go @@ -269,18 +269,21 @@ func AllowMerge(cluster sche.SharedCluster, region, adjacent *core.RegionInfo) b if cluster.GetSharedConfig().IsCrossTableMergeEnabled() { return true } - return isTableIDSame(region, adjacent) + return isSameTableIdentity(region, adjacent) case constant.Raw: return true case constant.Txn: return true default: - return isTableIDSame(region, adjacent) + return isSameTableIdentity(region, adjacent) } } -func isTableIDSame(region, adjacent *core.RegionInfo) bool { - return codec.Key(region.GetStartKey()).TableID() == codec.Key(adjacent.GetStartKey()).TableID() +// isSameTableIdentity reports whether two regions belong to the same logical +// table, i.e. the same table ID within the same keyspace (if any). +func isSameTableIdentity(region, adjacent *core.RegionInfo) bool { + return codec.Key(region.GetStartKey()).TableIdentity() == + codec.Key(adjacent.GetStartKey()).TableIdentity() } // Check whether there is a peer of the adjacent region on an offline store, diff --git a/pkg/schedule/checker/merge_checker_test.go b/pkg/schedule/checker/merge_checker_test.go index 80a72238ee4..75d528bfb1e 100644 --- a/pkg/schedule/checker/merge_checker_test.go +++ b/pkg/schedule/checker/merge_checker_test.go @@ -20,11 +20,13 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "go.uber.org/goleak" "github.com/pingcap/kvproto/pkg/metapb" + "github.com/tikv/pd/pkg/codec" "github.com/tikv/pd/pkg/core" "github.com/tikv/pd/pkg/core/storelimit" "github.com/tikv/pd/pkg/mock/mockcluster" @@ -590,3 +592,116 @@ func newRegionInfo(id uint64, startKey, endKey string, size, keys int64, leader core.SetApproximateKeys(keys), ) } + +func TestAllowMergeCrossTable(t *testing.T) { + ctx := t.Context() + cfg := mockconfig.NewTestOptions() + cluster := mockcluster.NewCluster(ctx, cfg) + // Disable placement rules so AllowMerge is decided only by key type / table ID. + cluster.SetEnablePlacementRules(false) + + const ( + keyspace1 = uint32(42) + keyspace2 = uint32(43) + tableA = int64(100) + tableB = int64(101) + indexID = int64(1) + ) + generateIndexKey := func(tableID, indexID int64) []byte { + key := append(codec.GenerateTableKey(tableID), '_', 'i') + return codec.EncodeInt(key, indexID) + } + + // Classic keys. + classicTableA := codec.EncodeBytes(codec.GenerateTableKey(tableA)) + classicTableB := codec.EncodeBytes(codec.GenerateTableKey(tableB)) + classicTableC := codec.EncodeBytes(codec.GenerateTableKey(tableB + 1)) + classicIndexA := codec.EncodeBytes(generateIndexKey(tableA, indexID)) + classicRecordA := codec.EncodeBytes(codec.GenerateRowKey(tableA, 1)) + + // Same-keyspace keys. + ks1TableA := encodeKeyspaceRawKey(keyspace1, codec.GenerateTableKey(tableA)) + ks1TableB := encodeKeyspaceRawKey(keyspace1, codec.GenerateTableKey(tableB)) + ks1TableC := encodeKeyspaceRawKey(keyspace1, codec.GenerateTableKey(tableB+1)) + ks1IndexA := encodeKeyspaceRawKey(keyspace1, generateIndexKey(tableA, indexID)) + ks1RecordA := encodeKeyspaceRawKey(keyspace1, codec.GenerateRowKey(tableA, 1)) + + // Different-keyspace keys. Adjacent ranges are constructed artificially so + // AllowMerge can reach the table-ID check; production keyspaces are usually + // separated by fence regions and would not be merge candidates. + ks2TableA := encodeKeyspaceRawKey(keyspace2, codec.GenerateTableKey(tableA)) + ks2TableB := encodeKeyspaceRawKey(keyspace2, codec.GenerateTableKey(tableB)) + ks2TableC := encodeKeyspaceRawKey(keyspace2, codec.GenerateTableKey(tableB+1)) + + // Matrix: layout × same/diff table identity × enable-cross-table-merge. + // Logical table identity is (keyspaceID, tableID); classic has no keyspace. + type adjacentPair struct { + startA, endA []byte + startB, endB []byte + } + classicDiffTable := adjacentPair{classicTableA, classicTableB, classicTableB, classicTableC} + classicSameTable := adjacentPair{classicIndexA, classicRecordA, classicRecordA, classicTableB} + sameKSDiffTable := adjacentPair{ks1TableA, ks1TableB, ks1TableB, ks1TableC} + sameKSSameTable := adjacentPair{ks1IndexA, ks1RecordA, ks1RecordA, ks1TableB} + // Different keyspaces with the same numeric table id are still different tables. + diffKSSameTable := adjacentPair{ks1TableA, ks2TableA, ks2TableA, ks2TableB} + diffKSDiffTable := adjacentPair{ks1TableA, ks2TableB, ks2TableB, ks2TableC} + + cases := []struct { + name string + pair adjacentPair + crossTableMerge bool + expectAllow bool + }{ + // Classic: different table IDs. + {"classic/diff-table/cross-enabled", classicDiffTable, true, true}, + {"classic/diff-table/cross-disabled", classicDiffTable, false, false}, + // Classic: same table ID (index + record). + {"classic/same-table/cross-enabled", classicSameTable, true, true}, + {"classic/same-table/cross-disabled", classicSameTable, false, true}, + + // Same keyspace: different table IDs. + {"same-keyspace/diff-table/cross-enabled", sameKSDiffTable, true, true}, + {"same-keyspace/diff-table/cross-disabled", sameKSDiffTable, false, false}, + // Same keyspace: same table ID (index + record). + {"same-keyspace/same-table/cross-enabled", sameKSSameTable, true, true}, + {"same-keyspace/same-table/cross-disabled", sameKSSameTable, false, true}, + + // Different keyspaces: same numeric table ID is not the same logical table. + // With cross-table enabled, merge is still allowed by policy (split keys + // from keyspace labels normally block this path in production). + {"diff-keyspace/same-table/cross-enabled", diffKSSameTable, true, true}, + {"diff-keyspace/same-table/cross-disabled", diffKSSameTable, false, false}, + // Different keyspaces: different table IDs. + {"diff-keyspace/diff-table/cross-enabled", diffKSDiffTable, true, true}, + {"diff-keyspace/diff-table/cross-disabled", diffKSDiffTable, false, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + re := require.New(t) + cluster.SetEnableCrossTableMerge(tc.crossTableMerge) + regionA := newRegionInfoWithBytes(1, tc.pair.startA, tc.pair.endA) + regionB := newRegionInfoWithBytes(2, tc.pair.startB, tc.pair.endB) + re.Equal(tc.expectAllow, AllowMerge(cluster, regionA, regionB)) + }) + } +} + +func encodeKeyspaceRawKey(keyspaceID uint32, rawKey []byte) []byte { + prefix := codec.MakeKeyspacePrefix(codec.TxnKeyspaceModePrefix, keyspaceID) + return codec.EncodeBytes(append(prefix, rawKey...)) +} + +func newRegionInfoWithBytes(id uint64, startKey, endKey []byte) *core.RegionInfo { + peer := &metapb.Peer{Id: id * 10, StoreId: 1} + return core.NewRegionInfo( + &metapb.Region{ + Id: id, + StartKey: startKey, + EndKey: endKey, + Peers: []*metapb.Peer{peer}, + }, + peer, + ) +} diff --git a/tests/server/cluster/cross_table_merge_test.go b/tests/server/cluster/cross_table_merge_test.go new file mode 100644 index 00000000000..21b496d8074 --- /dev/null +++ b/tests/server/cluster/cross_table_merge_test.go @@ -0,0 +1,103 @@ +// 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. +// 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 cluster_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/pingcap/kvproto/pkg/metapb" + + "github.com/tikv/pd/pkg/codec" + "github.com/tikv/pd/pkg/core" + "github.com/tikv/pd/pkg/utils/testutil" + "github.com/tikv/pd/pkg/utils/typeutil" + "github.com/tikv/pd/tests" +) + +// TestCrossTableMergeWithKeyspace verifies through a real pd-server that +// enable-cross-table-merge=false blocks merging adjacent regions of different +// tables under a keyspace, while same-table regions still merge. See #10991. +func TestCrossTableMergeWithKeyspace(t *testing.T) { + re := require.New(t) + tc, err := tests.NewTestCluster(t.Context(), 1) + defer tc.Destroy() + re.NoError(err) + re.NoError(tc.RunInitialServers()) + tc.WaitLeader() + leaderServer := tc.GetLeaderServer() + re.NoError(leaderServer.BootstrapCluster()) + tests.MustPutStore(re, tc, &metapb.Store{ + Id: 1, + State: metapb.StoreState_Up, + NodeState: metapb.NodeState_Serving, + LastHeartbeat: time.Now().UnixNano(), + }) + + // Mirror the issue reproduction: pd-ctl config set enable-cross-table-merge false. + svr := leaderServer.GetServer() + schedule := leaderServer.GetConfig().Schedule + schedule.EnableCrossTableMerge = false + schedule.SplitMergeInterval = typeutil.NewDuration(time.Second) + re.NoError(svr.SetScheduleConfig(schedule)) + replication := leaderServer.GetConfig().Replication + replication.MaxReplicas = 1 + re.NoError(svr.SetReplicationConfig(replication)) + + // Keyspace 42, txn mode. + keyspacePrefix := codec.MakeKeyspacePrefix(codec.TxnKeyspaceModePrefix, 42) + tableKey := func(tableID int64) []byte { + return codec.EncodeBytes(append(append([]byte{}, keyspacePrefix...), codec.GenerateTableKey(tableID)...)) + } + rowKey := func(tableID, rowID int64) []byte { + return codec.EncodeBytes(append(append([]byte{}, keyspacePrefix...), codec.GenerateRowKey(tableID, rowID)...)) + } + + // Five contiguous regions inside keyspace 42: three empty single-table + // regions (tables 100..102), then table 103 split at a row key so its two + // halves form a same-table merge control pair. + regions := []struct { + id uint64 + start, end []byte + }{ + {10, tableKey(100), tableKey(101)}, + {11, tableKey(101), tableKey(102)}, + {12, tableKey(102), tableKey(103)}, + {13, tableKey(103), rowKey(103, 500)}, + {14, rowKey(103, 500), tableKey(104)}, + } + for _, r := range regions { + tests.MustPutRegion(re, tc, r.id, 1, r.start, r.end, + core.SetApproximateSize(1), core.SetApproximateKeys(1)) + } + + oc := leaderServer.GetRaftCluster().GetOperatorController() + // The same-table pair must merge: proves the whole merge pipeline + // (patrol -> merge checker -> operator) is live in this setup. + testutil.Eventually(re, func() bool { + op13, op14 := oc.GetOperator(13), oc.GetOperator(14) + return op13 != nil && op14 != nil + }) + // Regions of different tables under the same keyspace must never be + // merged while enable-cross-table-merge is false. + for range 20 { + for _, id := range []uint64{10, 11, 12} { + re.Nil(oc.GetOperator(id), "unexpected operator on cross-table region %d", id) + } + time.Sleep(100 * time.Millisecond) + } +}