From 3ba46148d609b9d49931604c07c132d301492df4 Mon Sep 17 00:00:00 2001 From: wlwilliamx Date: Tue, 27 Jan 2026 17:27:45 +0800 Subject: [PATCH 1/2] common: accept legacy namespace in changefeed ID JSON --- pkg/common/types.go | 40 ++++++++++++++++++++++++ pkg/common/types_test.go | 66 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 pkg/common/types_test.go diff --git a/pkg/common/types.go b/pkg/common/types.go index ea792c8ed6..2e9501609d 100644 --- a/pkg/common/types.go +++ b/pkg/common/types.go @@ -16,6 +16,7 @@ package common import ( "bytes" "encoding/binary" + "encoding/json" "regexp" "strconv" @@ -160,6 +161,45 @@ type ChangeFeedDisplayName struct { Keyspace string `json:"keyspace"` } +// changeFeedDisplayNameJSON is a compatibility shim for ChangeFeedDisplayName JSON encoding. +// +// Why: some historical TiCDC versions persisted the user-facing dimension as "namespace". +// Newer TiCDC versions renamed it to "keyspace". Mixed-version upgrades/rollbacks must +// not make changefeeds "disappear" (e.g., keyspace becomes empty and gets filtered out) +// or become un-recreatable due to occupied meta keys. To keep metadata compatible across +// versions, we accept both field names on unmarshal and emit both on marshal. +type changeFeedDisplayNameJSON struct { + Name string `json:"name"` + Keyspace string `json:"keyspace,omitempty"` + Namespace string `json:"namespace,omitempty"` +} + +// MarshalJSON emits both "keyspace" (newer) and "namespace" (legacy) so that either +// field name can be used to read the metadata without an explicit migration step. +func (r ChangeFeedDisplayName) MarshalJSON() ([]byte, error) { + return json.Marshal(changeFeedDisplayNameJSON{ + Name: r.Name, + Keyspace: r.Keyspace, + Namespace: r.Keyspace, + }) +} + +// UnmarshalJSON accepts both "keyspace" (newer) and "namespace" (legacy). +// If both are present, "keyspace" takes precedence. +func (r *ChangeFeedDisplayName) UnmarshalJSON(data []byte) error { + var decoded changeFeedDisplayNameJSON + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + r.Name = decoded.Name + if decoded.Keyspace != "" { + r.Keyspace = decoded.Keyspace + return nil + } + r.Keyspace = decoded.Namespace + return nil +} + func NewChangeFeedDisplayName(name string, keyspace string) ChangeFeedDisplayName { return ChangeFeedDisplayName{ Name: name, diff --git a/pkg/common/types_test.go b/pkg/common/types_test.go new file mode 100644 index 0000000000..61f9b1c819 --- /dev/null +++ b/pkg/common/types_test.go @@ -0,0 +1,66 @@ +package common + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestChangeFeedDisplayNameJSONCompatibility(t *testing.T) { + t.Parallel() + + // Scenario: TiCDC historically persisted changefeed IDs using "namespace". + // Newer versions renamed the dimension to "keyspace". We must be able to + // read both to avoid cross-version upgrades/rollbacks making changefeeds + // "disappear" (e.g., keyspace becomes empty and gets filtered out). + + t.Run("unmarshal legacy namespace", func(t *testing.T) { + t.Parallel() + + // Step: decode legacy JSON that only contains "namespace". + var got ChangeFeedDisplayName + require.NoError(t, json.Unmarshal([]byte(`{"name":"cf","namespace":"default"}`), &got)) + + // Expect: Keyspace is filled from the legacy field. + require.Equal(t, ChangeFeedDisplayName{Name: "cf", Keyspace: "default"}, got) + }) + + t.Run("unmarshal new keyspace", func(t *testing.T) { + t.Parallel() + + // Step: decode newer JSON that only contains "keyspace". + var got ChangeFeedDisplayName + require.NoError(t, json.Unmarshal([]byte(`{"name":"cf","keyspace":"default"}`), &got)) + + // Expect: Keyspace is filled from the new field. + require.Equal(t, ChangeFeedDisplayName{Name: "cf", Keyspace: "default"}, got) + }) + + t.Run("keyspace wins when both exist", func(t *testing.T) { + t.Parallel() + + // Step: decode mixed JSON produced/consumed by different versions. + var got ChangeFeedDisplayName + require.NoError(t, json.Unmarshal([]byte(`{"name":"cf","namespace":"ns","keyspace":"ks"}`), &got)) + + // Expect: new field takes precedence to match current semantics. + require.Equal(t, ChangeFeedDisplayName{Name: "cf", Keyspace: "ks"}, got) + }) + + t.Run("marshal emits both fields", func(t *testing.T) { + t.Parallel() + + // Step: encode a display name. + data, err := json.Marshal(ChangeFeedDisplayName{Name: "cf", Keyspace: "default"}) + require.NoError(t, err) + + // Expect: both fields exist so that either a legacy ("namespace") or a newer + // ("keyspace") TiCDC binary can read metadata without rewriting it first. + var got map[string]any + require.NoError(t, json.Unmarshal(data, &got)) + require.Equal(t, "cf", got["name"]) + require.Equal(t, "default", got["namespace"]) + require.Equal(t, "default", got["keyspace"]) + }) +} From 01d4003c31937697c203b63b3b3cd40c78ef3f0e Mon Sep 17 00:00:00 2001 From: wlwilliamx Date: Mon, 8 Jun 2026 12:53:09 +0800 Subject: [PATCH 2/2] common: add copyright header to types test --- pkg/common/types_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pkg/common/types_test.go b/pkg/common/types_test.go index 61f9b1c819..b1685afca8 100644 --- a/pkg/common/types_test.go +++ b/pkg/common/types_test.go @@ -1,3 +1,16 @@ +// Copyright 2025 PingCAP, Inc. +// +// 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, +// See the License for the specific language governing permissions and +// limitations under the License. + package common import (