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
40 changes: 40 additions & 0 deletions pkg/common/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package common
import (
"bytes"
"encoding/binary"
"encoding/json"
"regexp"
"strconv"

Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Rewriting the UnmarshalJSON method is significantly better than defining a new struct.

@wlwilliamx wlwilliamx Feb 12, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I already rewrited UnmarshalJSON on ChangeFeedDisplayName. We keep the unexported changeFeedDisplayNameJSON shim only to share the JSON tags between MarshalJSON/UnmarshalJSON (to keep them symmetric and avoid duplication/drift) and to avoid unmarshalling twice.

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think we don't need to Marshal the Namespace field

})
}

// 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
Comment on lines +195 to +199

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The logic for setting r.Keyspace can be made more concise and arguably more readable by removing the conditional early return.

Suggested change
if decoded.Keyspace != "" {
r.Keyspace = decoded.Keyspace
return nil
}
r.Keyspace = decoded.Namespace
r.Keyspace = decoded.Keyspace
if decoded.Keyspace == "" {
r.Keyspace = decoded.Namespace
}

return nil
}

func NewChangeFeedDisplayName(name string, keyspace string) ChangeFeedDisplayName {
return ChangeFeedDisplayName{
Name: name,
Expand Down
79 changes: 79 additions & 0 deletions pkg/common/types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// 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 (
"encoding/json"
"testing"

"github.com/stretchr/testify/require"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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"])
})
}
Loading