diff --git a/pkg/logservice/gossip_fixture_test.go b/pkg/logservice/gossip_fixture_test.go index 558cfc1425828..e4069a2d14848 100644 --- a/pkg/logservice/gossip_fixture_test.go +++ b/pkg/logservice/gossip_fixture_test.go @@ -686,6 +686,25 @@ func TestGossipFixtureStartupConfigurationFailure(t *testing.T) { } } +func TestGossipFixtureConfigPortRange(t *testing.T) { + first, limit, err := testPortRange() + require.NoError(t, err) + rt := runtime.DefaultRuntime() + configs, err := makeGossipFixtureConfigs(t, rt, 2) + defer func() { require.NoError(t, closeGossipFixture(rt, configs, nil)) }() + require.NoError(t, err) + require.Len(t, configs, 2) + seen := make(map[int]struct{}) + for _, cfg := range configs { + for _, port := range []int{cfg.GossipPort, cfg.RaftPort, cfg.LogServicePort} { + require.GreaterOrEqual(t, port, first) + require.Less(t, port, limit) + require.NotContains(t, seen, port) + seen[port] = struct{}{} + } + } +} + func TestGossipFixtureConfigAllocationFailure(t *testing.T) { for _, tc := range []struct { name string diff --git a/pkg/sql/plan/bind_delete.go b/pkg/sql/plan/bind_delete.go index d9fd9a01cd58a..dd9bd6e3ad197 100644 --- a/pkg/sql/plan/bind_delete.go +++ b/pkg/sql/plan/bind_delete.go @@ -48,6 +48,7 @@ func canDeleteRewriteToTruncate(ctx CompilerContext, dmlCtx *DMLContext) (bool, for i, tableDef := range dmlCtx.tableDefs { if enabled && len(tableDef.RefChildTbls) > 0 || + tableDef.IsTemporary || tableDef.ViewSql != nil || (dmlCtx.isClusterTable[i] && accountId != catalog.System_Account) || dmlCtx.objRefs[i].PubInfo != nil || diff --git a/pkg/sql/plan/build_dml_util.go b/pkg/sql/plan/build_dml_util.go index 5229b029ac228..26b1efa8e0d61 100644 --- a/pkg/sql/plan/build_dml_util.go +++ b/pkg/sql/plan/build_dml_util.go @@ -1058,7 +1058,9 @@ func buildDeletePlans(ctx CompilerContext, builder *QueryBuilder, bindCtx *BindC // Refer to this PR:https://github.com/matrixorigin/matrixone/pull/12093 // we have build SK using UK code path. So we might see UK in function signature even thought it could be for // both UK and SK. To handle SK case, we will have flags to indicate if it's UK or SK. - canTruncate := delCtx.isUnrestrictedDelete + // Temporary DELETE is transactional row DML. A physical replacement would + // enter the persistent-table lifecycle using its private physical name. + canTruncate := delCtx.isUnrestrictedDelete && !delCtx.tableDef.IsTemporary enabled, err := IsForeignKeyChecksEnabled(ctx) if err != nil { @@ -7061,7 +7063,9 @@ func buildDeleteIndexPlans(ctx CompilerContext, builder *QueryBuilder, bindCtx * // both UK and SK. To handle SK case, we will have flags to indicate if it's UK or SK. hasUniqueKey := haveUniqueKey(delCtx.tableDef) hasSecondaryKey := haveSecondaryKey(delCtx.tableDef) - canTruncate := delCtx.isUnrestrictedDelete + // Keep temporary index maintenance on the same row-delete path as its base + // table; skipping it would leave stale index entries after DELETE. + canTruncate := delCtx.isUnrestrictedDelete && !delCtx.tableDef.IsTemporary accountId, err := ctx.GetAccountId() if err != nil { diff --git a/pkg/sql/plan/temporary_delete_test.go b/pkg/sql/plan/temporary_delete_test.go new file mode 100644 index 0000000000000..f151590304c79 --- /dev/null +++ b/pkg/sql/plan/temporary_delete_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 Matrix Origin +// +// 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 plan + +import ( + "testing" + + planpb "github.com/matrixorigin/matrixone/pkg/pb/plan" + "github.com/matrixorigin/matrixone/pkg/sql/parsers/dialect/mysql" + "github.com/matrixorigin/matrixone/pkg/sql/parsers/tree" + "github.com/stretchr/testify/require" +) + +func TestTemporaryDeleteDoesNotRewriteToTruncate(t *testing.T) { + for _, temporary := range []bool{false, true} { + name := "permanent" + if temporary { + name = "temporary" + } + t.Run(name, func(t *testing.T) { + mock := NewMockOptimizer(true, newPlanTestProcess(t)) + table := mock.ctxt.tables["nation"] + table.IsTemporary = temporary + ctx := &DMLContext{ + tableDefs: []*TableDef{table}, + objRefs: []*ObjectRef{{SchemaName: "tpch", ObjName: "nation"}}, + isClusterTable: []bool{false}, + } + allowed, err := canDeleteRewriteToTruncate(&mock.ctxt, ctx) + require.NoError(t, err) + require.Equal(t, !temporary, allowed) + + // Exercise the legacy planner too: it has a separate truncate + // eligibility check and must not reintroduce a physical replacement. + stmt, err := mysql.ParseOne(t.Context(), "delete from tpch.nation", 1) + require.NoError(t, err) + defer stmt.Free() + p, err := buildDelete(stmt.(*tree.Delete), &mock.ctxt, false) + require.NoError(t, err) + found := false + for _, node := range p.GetQuery().Nodes { + if node.NodeType == planpb.Node_DELETE && node.DeleteCtx.TableDef.TblId == table.TblId { + found = true + require.Equal(t, !temporary, node.DeleteCtx.CanTruncate) + } + } + require.True(t, found, "expected an origin-table DELETE node") + }) + } +} diff --git a/pkg/tests/issues/issue_30089_test.go b/pkg/tests/issues/issue_30089_test.go new file mode 100644 index 0000000000000..7ce1a84c291dc --- /dev/null +++ b/pkg/tests/issues/issue_30089_test.go @@ -0,0 +1,112 @@ +// Copyright 2026 Matrix Origin +// +// 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 issues + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/matrixorigin/matrixone/pkg/embed" + "github.com/stretchr/testify/require" +) + +func TestIssue30089TemporaryDeleteAvoidsPersistentLifecycle(t *testing.T) { + runAuthenticatedClusterTest(t, func(cluster embed.Cluster) { + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + defer cancel() + cn0, err := cluster.GetCNService(0) + require.NoError(t, err) + cn1, err := cluster.GetCNService(1) + require.NoError(t, err) + db0, err := sql.Open("mysql", issue27487DSN(cn0.GetServiceConfig().CN.Frontend.Port)) + require.NoError(t, err) + defer db0.Close() + db1, err := sql.Open("mysql", issue27487DSN(cn1.GetServiceConfig().CN.Frontend.Port)) + require.NoError(t, err) + defer db1.Close() + const database = "issue30089_temp_delete" + execSQLRequire(t, ctx, db0, "create database "+database) + defer func() { + cleanup, stop := context.WithTimeout(context.Background(), 20*time.Second) + defer stop() + _, err := db0.ExecContext(cleanup, "drop database if exists "+database) + require.NoError(t, err) + }() + for _, indexed := range []bool{false, true} { + t.Run(fmt.Sprintf("indexed_%t", indexed), func(t *testing.T) { + conn, err := db0.Conn(ctx) + require.NoError(t, err) + defer conn.Close() + exec := func(query string) { + t.Helper() + _, err := conn.ExecContext(ctx, query) + require.NoError(t, err, query) + } + exec("use " + database) + table := fmt.Sprintf("t_%t", indexed) + exec("create table " + table + "(id int primary key, v int)") + exec("insert into " + table + " values(99,99)") + definition := "(id int primary key, v int" + if indexed { + definition += ", unique key uk(v)" + } + exec("create temporary table " + table + definition + ")") + defer func() { + cleanup, stop := context.WithTimeout(context.Background(), 20*time.Second) + defer stop() + _, _ = conn.ExecContext(cleanup, "rollback") + _, err := conn.ExecContext(cleanup, "drop temporary table if exists "+table) + require.NoError(t, err) + }() + exec("insert into " + table + " values(2,20)") + exec("begin") + exec("update " + table + " set id=4, v=40") + + // A real shared owner on the other CN is a deterministic barrier: + // the old physical TRUNCATE rewrite tries to promote to G X and + // fails because UPDATE has already retained table locks. + holder, err := db1.BeginTx(ctx, nil) + require.NoError(t, err) + defer holder.Rollback() + var feature string + require.NoError(t, holder.QueryRowContext(ctx, + "select feature_code from mo_catalog.mo_feature_registry where feature_code='SNAPSHOT' for share").Scan(&feature)) + require.Equal(t, "SNAPSHOT", feature) + deleteCtx, stop := context.WithTimeout(ctx, 10*time.Second) + _, err = conn.ExecContext(deleteCtx, "delete from "+table) + stop() + require.NoError(t, err) + exec("insert into " + table + " values(5,40)") + exec("rollback") + var id, value int + require.NoError(t, conn.QueryRowContext(ctx, "select id,v from "+table).Scan(&id, &value)) + require.Equal(t, 2, id) + require.Equal(t, 20, value) + // Committed DELETE must remove the unique index entry too. + exec("delete from " + table) + exec("insert into " + table + " values(3,20)") + require.NoError(t, conn.QueryRowContext(ctx, "select id from "+table+" where v=20").Scan(&id)) + require.Equal(t, 3, id) + require.NoError(t, holder.Rollback()) + // The same-name permanent relation is not the delete target. + require.NoError(t, db1.QueryRowContext(ctx, "select id from "+database+"."+table).Scan(&id)) + require.Equal(t, 99, id) + }) + } + }) +}