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
19 changes: 19 additions & 0 deletions changelog/24.0/24.0.0/summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- **[Major Changes](#major-changes)**
- **[New Support](#new-support)**
- [Window function pushdown for sharded keyspaces](#window-function-pushdown)
- [Tablet targeting via USE statement](#tablet-targeting)
- **[Minor Changes](#minor-changes)**
- **[VTGate](#minor-changes-vtgate)**
- [New default for `--legacy-replication-lag-algorithm` flag](#vtgate-new-default-legacy-replication-lag-algorithm)
Expand Down Expand Up @@ -34,6 +35,24 @@ Previously, all window function queries required single-shard routing, which lim

For examples and more details, see the [documentation](https://vitess.io/docs/24.0/reference/compatibility/mysql-compatibility/#window-functions).

#### <a id="tablet-targeting"/>Tablet targeting via USE statement</a>
Copy link
Contributor

@timvaillancourt timvaillancourt Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nickvanw let's say I use this feature to write rows to a PRIMARY that doesn't align with the VSchema, am I creating any data correctness/etc risks we should detail here?

I'm wondering if that would cause query failures or potentially-surprising results. But at the same time, I guess you can only use this feature with intention 🤔

And on a similar track, could vReplication get upset about rows in the wrong shard? One dreamed-up scenario I can think of is merging shards, and the same primary key exists in both, etc

None of what I'm thinking means changing the code here I think (it looks good so far), maybe just documentation

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's say I use this feature to write rows to a PRIMARY that doesn't align with the VSchema, am I creating any data correctness/etc risks we should detail here?

I don't think the introduction of this feature changes this - you can already target specific shards and side-step any validation that Vitess would normally do against the queries you are executing (like ensuring inserts only are routed to the "correct" shard).


VTGate now supports routing queries to a specific tablet by alias using an extended `USE` statement syntax:

```sql
USE keyspace:shard@tablet_type|tablet_alias;
```

For example, to target a specific replica tablet:

```sql
USE commerce:-80@replica|zone1-0000000100;
```

Once set, all subsequent queries in the session route to the specified tablet until cleared with a standard `USE keyspace` or `USE keyspace@tablet_type` statement. This is useful for debugging, per-tablet monitoring, cache warming, and other operational tasks where targeting a specific tablet is required.

Note: A shard must be specified when using tablet targeting. Like shard targeting, this bypasses vindex-based routing, so use with care.

## <a id="minor-changes"/>Minor Changes</a>

### <a id="minor-changes-vtgate"/>VTGate</a>
Expand Down
2 changes: 1 addition & 1 deletion go/test/endtoend/vtgate/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func TestMain(m *testing.M) {
clusterInstance.VtTabletExtraArgs = append(clusterInstance.VtTabletExtraArgs,
"--queryserver-config-max-result-size", "100",
"--queryserver-config-terse-errors")
err = clusterInstance.StartKeyspace(*keyspace, []string{"-80", "80-"}, 0, false, clusterInstance.Cell)
err = clusterInstance.StartKeyspace(*keyspace, []string{"-80", "80-"}, 2, false, clusterInstance.Cell)
if err != nil {
return 1
}
Expand Down
164 changes: 164 additions & 0 deletions go/test/endtoend/vtgate/misc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ limitations under the License.
package vtgate

import (
"context"
"fmt"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -839,6 +840,169 @@ func TestDDLTargeted(t *testing.T) {
utils.AssertMatches(t, conn, `select id from ddl_targeted`, `[[INT64(1)]]`)
}

// TestTabletTargeting tests tablet-specific routing with USE keyspace:shard@tablet-alias syntax.
// When shard is specified, tablet-specific routing bypasses vindex-based shard resolution.
func TestTabletTargeting(t *testing.T) {
ctx := context.Background()
conn, err := mysql.Connect(ctx, &vtParams)
require.NoError(t, err)
defer conn.Close()

instances := make(map[string]map[string][]string)

for _, ks := range clusterInstance.Keyspaces {
if ks.Name != "ks" {
continue
}
for _, shard := range ks.Shards {
instances[shard.Name] = make(map[string][]string)
for _, tablet := range shard.Vttablets {
instances[shard.Name][tablet.Type] = append(instances[shard.Name][tablet.Type], tablet.Alias)
}
}
}

require.NotEmpty(t, instances["-80"]["primary"][0], "no PRIMARY tablet found for -80 shard")
require.NotEmpty(t, instances["80-"]["primary"][0], "no PRIMARY tablet found for 80- shard")
require.NotEmpty(t, instances["-80"]["replica"], "no REPLICA tablets found for -80 shard")
require.NotEmpty(t, instances["80-"]["replica"], "no REPLICA tablets found for 80- shard")

// Insert data that would normally hash to 80- shard, but goes to -80 because of shard targeting
useStmt := fmt.Sprintf("USE `ks:-80@primary|%s`", instances["-80"]["primary"][0])
utils.Exec(t, conn, useStmt)
utils.Exec(t, conn, "INSERT into t1(id1, id2) values(1, 100), (2, 200)")
// id1=4 hashes to 80-, but we're targeting -80 shard explicitly
utils.Exec(t, conn, "insert into t1(id1, id2) values(4, 400)")

// Verify the data went to -80 shard (not where vindex would have put it)
utils.Exec(t, conn, "USE `ks:-80`")
utils.AssertMatches(t, conn, "select id1 from t1 where id1 in (1, 2, 4) order by id1", "[[INT64(1)] [INT64(2)] [INT64(4)]]")

// Verify the data did NOT go to 80- shard (where vindex says id1=4 should be)
utils.Exec(t, conn, "USE `ks:80-`")
utils.AssertIsEmpty(t, conn, "select id1 from t1 where id1=4")

// Transaction with tablet-specific routing maintains sticky connection
useStmt = fmt.Sprintf("USE `ks:-80@primary|%s`", instances["-80"]["primary"][0])
utils.Exec(t, conn, useStmt)
utils.Exec(t, conn, "begin")
utils.Exec(t, conn, "insert into t1(id1, id2) values(10, 300)")
// Subsequent queries in transaction should go to same tablet
utils.AssertMatches(t, conn, "select id1 from t1 where id1=10", "[[INT64(10)]]")
utils.Exec(t, conn, "commit")
utils.AssertMatches(t, conn, "select id1 from t1 where id1=10", "[[INT64(10)]]")

// Rollback with tablet-specific routing
useStmt = fmt.Sprintf("USE `ks:-80@primary|%s`", instances["-80"]["primary"][0])
utils.Exec(t, conn, useStmt)
utils.Exec(t, conn, "begin")
utils.Exec(t, conn, "insert into t1(id1, id2) values(20, 500)")
utils.Exec(t, conn, "rollback")
utils.AssertIsEmpty(t, conn, "select id1 from t1 where id1=20")

// Invalid tablet alias should fail
useStmt = "USE `ks:-80@primary|nonexistent-tablet`"
_, err = conn.ExecuteFetch(useStmt, 1, false)
require.Error(t, err, "query should fail on invalid tablet")
require.Contains(t, err.Error(), "invalid tablet alias in target")

// Tablet alias without shard should fail
useStmt = fmt.Sprintf("USE `ks@primary|%s`", instances["-80"]["primary"][0])
_, err = conn.ExecuteFetch(useStmt, 1, false)
require.Error(t, err, "tablet alias must be used with a shard")

// Clear tablet targeting returns to normal routing
// With normal routing, the query for id1=4 will be sent to the wrong shard (80-), so it won't be found.
// This is expected and demonstrates that vindex routing is back in effect.
utils.Exec(t, conn, "USE ks")
utils.AssertMatches(t, conn, "select id1 from t1 where id1 in (1, 2, 4, 10) order by id1", "[[INT64(1)] [INT64(2)] [INT64(10)]]")

// Targeting a specific REPLICA tablet allows reads but not writes
replicaAlias := instances["-80"]["replica"][0]
useStmt = fmt.Sprintf("USE `ks:-80@replica|%s`", replicaAlias)
utils.Exec(t, conn, useStmt)

// Reads should work on replica (wait for replication)
require.Eventually(t, func() bool {
result, err := conn.ExecuteFetch("select id1 from t1 where id1 in (1, 2, 4, 10) order by id1", 10, false)
return err == nil && len(result.Rows) == 4
}, 15*time.Second, 100*time.Millisecond, "replication did not catch up for first replica read")

// Writes should fail on replica (replicas are read-only)
_, err = conn.ExecuteFetch("insert into t1(id1, id2) values(99, 999)", 1, false)
require.Error(t, err, "write should fail on replica tablet")
require.Contains(t, err.Error(), "1290")

// Targeting different REPLICA tablets in the same shard
secondReplicaAlias := instances["-80"]["replica"][1]
useStmt = fmt.Sprintf("USE `ks:-80@replica|%s`", secondReplicaAlias)
utils.Exec(t, conn, useStmt)

// Should still be able to read from this different replica (wait for replication)
require.Eventually(t, func() bool {
result, err := conn.ExecuteFetch("select id1 from t1 where id1 in (1, 2, 4, 10) order by id1", 10, false)
return err == nil && len(result.Rows) == 4
}, 15*time.Second, 100*time.Millisecond, "replication did not catch up for second replica read")

// Writes should still fail
_, err = conn.ExecuteFetch("insert into t1(id1, id2) values(98, 998)", 1, false)
require.Error(t, err, "write should fail on replica tablet")
require.Contains(t, err.Error(), "1290")

// Write to primary, verify it replicates to replica
// This tests that tablet-specific routing doesn't break replication
useStmt = fmt.Sprintf("USE `ks:-80@primary|%s`", instances["-80"]["primary"][0])
utils.Exec(t, conn, useStmt)
utils.Exec(t, conn, "insert into t1(id1, id2) values(50, 5000)")
utils.AssertMatches(t, conn, "select id1 from t1 where id1=50", "[[INT64(50)]]")

// Switch to replica and verify the data replicated
replicaAlias = instances["-80"]["replica"][0]
useStmt = fmt.Sprintf("USE `ks:-80@replica|%s`", replicaAlias)
utils.Exec(t, conn, useStmt)
// Wait for replication to catch up
require.Eventually(t, func() bool {
result, err := conn.ExecuteFetch("select id1 from t1 where id1=50", 1, false)
return err == nil && len(result.Rows) == 1
}, 15*time.Second, 100*time.Millisecond, "replication did not catch up")

// Query different replicas and verify different server UUIDs
// This proves we're actually hitting different physical tablets

// Get server UUID from first replica
useStmt = fmt.Sprintf("USE `ks:-80@replica|%s`", instances["-80"]["replica"][0])
utils.Exec(t, conn, useStmt)
var uuid1 string
for i := range 5 {
result1 := utils.Exec(t, conn, "SELECT @@server_uuid")
require.NotNil(t, result1)
require.Greater(t, len(result1.Rows), 0)
if i > 0 {
// UUID should be the same across multiple queries to same tablet
require.Equal(t, uuid1, result1.Rows[0][0].ToString())
}
uuid1 = result1.Rows[0][0].ToString()
}

// Get server UUID from second replica
useStmt = fmt.Sprintf("USE `ks:-80@replica|%s`", instances["-80"]["replica"][1])
utils.Exec(t, conn, useStmt)
var uuid2 string
for i := range 5 {
result2 := utils.Exec(t, conn, "SELECT @@server_uuid")
require.NotNil(t, result2)
require.Greater(t, len(result2.Rows), 0)
if i > 0 {
// UUID should be the same across multiple queries to same tablet
require.Equal(t, uuid2, result2.Rows[0][0].ToString())
}
uuid2 = result2.Rows[0][0].ToString()
}

// Server UUIDs should be different, proving we're targeting different tablets
require.NotEqual(t, uuid1, uuid2, "different replicas should have different server UUIDs")
}

// TestDynamicConfig tests the dynamic configurations.
func TestDynamicConfig(t *testing.T) {
t.Run("DiscoveryLowReplicationLag", func(t *testing.T) {
Expand Down
8 changes: 4 additions & 4 deletions go/test/vschemawrapper/vschema_wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ func (vw *VSchemaWrapper) ShardDestination() key.ShardDestination {
}

func (vw *VSchemaWrapper) FindTable(tab sqlparser.TableName) (*vindexes.BaseTable, string, topodatapb.TabletType, key.ShardDestination, error) {
destKeyspace, destTabletType, destTarget, err := topoproto.ParseDestination(tab.Qualifier.String(), topodatapb.TabletType_PRIMARY)
destKeyspace, destTabletType, destTarget, _, err := topoproto.ParseDestination(tab.Qualifier.String(), topodatapb.TabletType_PRIMARY)
if err != nil {
return nil, destKeyspace, destTabletType, destTarget, err
}
Expand All @@ -248,15 +248,15 @@ func (vw *VSchemaWrapper) FindTable(tab sqlparser.TableName) (*vindexes.BaseTabl
}

func (vw *VSchemaWrapper) FindView(tab sqlparser.TableName) sqlparser.TableStatement {
destKeyspace, _, _, err := topoproto.ParseDestination(tab.Qualifier.String(), topodatapb.TabletType_PRIMARY)
destKeyspace, _, _, _, err := topoproto.ParseDestination(tab.Qualifier.String(), topodatapb.TabletType_PRIMARY)
if err != nil {
return nil
}
return vw.V.FindView(destKeyspace, tab.Name.String())
}

func (vw *VSchemaWrapper) FindViewTarget(name sqlparser.TableName) (*vindexes.Keyspace, error) {
destKeyspace, _, _, err := topoproto.ParseDestination(name.Qualifier.String(), topodatapb.TabletType_PRIMARY)
destKeyspace, _, _, _, err := topoproto.ParseDestination(name.Qualifier.String(), topodatapb.TabletType_PRIMARY)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -336,7 +336,7 @@ func (vw *VSchemaWrapper) IsViewsEnabled() bool {
// FindMirrorRule finds the mirror rule for the requested keyspace, table
// name, and the tablet type in the VSchema.
func (vw *VSchemaWrapper) FindMirrorRule(tab sqlparser.TableName) (*vindexes.MirrorRule, error) {
destKeyspace, destTabletType, _, err := topoproto.ParseDestination(tab.Qualifier.String(), topodatapb.TabletType_PRIMARY)
destKeyspace, destTabletType, _, _, err := topoproto.ParseDestination(tab.Qualifier.String(), topodatapb.TabletType_PRIMARY)
if err != nil {
return nil, err
}
Expand Down
49 changes: 40 additions & 9 deletions go/vt/topo/topoproto/destination.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,44 @@ import (

// ParseDestination parses the string representation of a ShardDestination
// of the form keyspace:shard@tablet_type. You can use a / instead of a :.
func ParseDestination(targetString string, defaultTabletType topodatapb.TabletType) (string, topodatapb.TabletType, key.ShardDestination, error) {
// It also supports tablet-specific routing with keyspace:shard@tablet_type|tablet-alias
// where tablet-alias is in the format cell-uid (e.g., zone1-0000000100).
// The tablet_type|tablet-alias syntax explicitly specifies both the expected tablet type
// and the specific tablet to route to (e.g., @replica|zone1-0000000100).
func ParseDestination(targetString string, defaultTabletType topodatapb.TabletType) (string, topodatapb.TabletType, key.ShardDestination, *topodatapb.TabletAlias, error) {
var dest key.ShardDestination
var keyspace string
var tabletAlias *topodatapb.TabletAlias
tabletType := defaultTabletType

last := strings.LastIndexAny(targetString, "@")
if last != -1 {
// No need to check the error. UNKNOWN will be returned on
// error and it will fail downstream.
tabletType, _ = ParseTabletType(targetString[last+1:])
afterAt := targetString[last+1:]
if afterAt == "" {
return "", defaultTabletType, nil, nil, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "empty tablet type after @")
}
// Check for explicit tablet_type|tablet_alias syntax (e.g., "replica|zone1-0000000100")
if pipeIdx := strings.Index(afterAt, "|"); pipeIdx != -1 {
typeStr := afterAt[:pipeIdx]
aliasStr := afterAt[pipeIdx+1:]

// Parse the tablet type
parsedTabletType, err := ParseTabletType(typeStr)
if err != nil || parsedTabletType == topodatapb.TabletType_UNKNOWN {
return "", defaultTabletType, nil, nil, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "invalid tablet type in target: %s", typeStr)
}
tabletType = parsedTabletType

// Parse the tablet alias
alias, err := ParseTabletAlias(aliasStr)
if err != nil {
return "", defaultTabletType, nil, nil, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "invalid tablet alias in target: %s", aliasStr)
}
tabletAlias = alias
} else {
// No pipe: just a tablet type (backward compatible - allow UNKNOWN)
tabletType, _ = ParseTabletType(afterAt)
}
targetString = targetString[:last]
}
last = strings.LastIndexAny(targetString, "/:")
Expand All @@ -51,29 +79,32 @@ func ParseDestination(targetString string, defaultTabletType topodatapb.TabletTy
if last != -1 {
rangeEnd := strings.LastIndexAny(targetString, "]")
if rangeEnd == -1 {
return keyspace, tabletType, dest, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "invalid key range provided. Couldn't find range end ']'")
return keyspace, tabletType, dest, nil, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "invalid key range provided. Couldn't find range end ']'")
}
rangeString := targetString[last+1 : rangeEnd]
if strings.Contains(rangeString, "-") {
// Parse as range
keyRange, err := key.ParseShardingSpec(rangeString)
if err != nil {
return keyspace, tabletType, dest, err
return keyspace, tabletType, dest, nil, err
}
if len(keyRange) != 1 {
return keyspace, tabletType, dest, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "single keyrange expected in %s", rangeString)
return keyspace, tabletType, dest, nil, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "single keyrange expected in %s", rangeString)
}
dest = key.DestinationExactKeyRange{KeyRange: keyRange[0]}
} else {
// Parse as keyspace id
destBytes, err := hex.DecodeString(rangeString)
if err != nil {
return keyspace, tabletType, dest, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "expected valid hex in keyspace id %s", rangeString)
return keyspace, tabletType, dest, nil, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "expected valid hex in keyspace id %s", rangeString)
}
dest = key.DestinationKeyspaceID(destBytes)
}
targetString = targetString[:last]
}
keyspace = targetString
return keyspace, tabletType, dest, nil
if tabletAlias != nil && dest == nil {
return "", defaultTabletType, nil, nil, vterrors.Errorf(vtrpcpb.Code_INVALID_ARGUMENT, "tablet alias must be used with a shard")
}
return keyspace, tabletType, dest, tabletAlias, nil
Comment on lines +106 to +109
Copy link
Member

@harshit-gangal harshit-gangal Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is a destination then it will not go through the planner? The query will be shard targeted.
Is this the expectation?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the expectation, which I think makes sense - this is basically shard targeting++, with the ability to shard target + target a specific tablet.

}
Loading
Loading