Repository: pingcap/tiflow
Pull request #12123: sink(ticdc): waiting the asynchronous ddl to synchronize
Author: login: wk989898
Labels: name: size/XXL
description: Denotes a PR that changes 1000+ lines, ignoring generated files.
name: release-note
description: Denotes a PR that will be considered when it comes time to generate release notes.
name: approved
name: lgtm
name: needs-cherry-pick-release-8.5
description: Should cherry pick this PR to release-8.5 branch.
Created: 2025-04-07T08:15:33Z | Merged: 2025-04-22T03:39:47Z

<!--
Thank you for contributing to TiFlow! 
Please read MD's [CONTRIBUTING](https://github.com/pingcap/tiflow/blob/master/CONTRIBUTING.md) document **BEFORE** filing this PR.
-->

### What problem does this PR solve?
<!--
Please create an issue first to describe the problem.

There MUST be one line starting with "Issue Number:  " and 
linking the relevant issues via the "close" or "ref".

For more info, check https://pingcap.github.io/tidb-dev-guide/contribute-to-tidb/contribute-code.html#referring-to-an-issue.
 -->

Issue Number: close #12114 close #12128

### What is changed and how it works?
When a DDL operation takes a long time, the connection may be lost, even though the DDL is actually executed. Instead of throwing a failure state, we can query the downstream and wait for the DDL to complete.

**Notice**: 
- The add index DDL operates the same as before and does not require waiting.
- Waiting DDL mechanism only works when downstream is TiDB and the user is at the root.
### Check List <!--REMOVE the items that are not applicable-->

#### Tests <!-- At least one of them must be included. -->

 - Unit test
 - Integration test
 - manual test
 ```sql
mysql> select count(*) from information_schema.ddl_jobs where CREATE_TIME >= now() - interval 10 MINUTE;
+----------+
| count(*) |
+----------+
|     9000 |
+----------+
1 row in set (0.90 sec)
mysql> SELECT JOB_ID, JOB_TYPE, SCHEMA_STATE, SCHEMA_ID, TABLE_ID, STATE, QUERY FROM information_schema.ddl_jobs where CREATE_TIME >= now()- interval 10 MINUTE;
...
...
...
9000 rows in set (0.91 sec)
```

#### Questions <!-- Authors should answer these questions and reviewers should consider these questions. -->

##### Will it cause performance regression or break compatibility?

##### Do you need to update user documentation, design documentation or monitoring documentation?

### Release note <!-- bugfixes or new features need a release note -->

```release-note
Wait for the DDL to synchronize when meeting MySQL Invalid connection error
```


Linked issue #12114: modify column  was retried repeatedly by ticdc and after a time, changefeed status was failed
### What did you do?

1. upstream execute 'alter table xxx modify column value decimal(16,6)'.  The table has 140 million rows.  
2. downstream modify column cost more than 8 hours.

![Image](https://github.com/user-attachments/assets/d9fc298a-099b-4e21-ab28-42e29e610891)

3. TiCDC retried 20 times and failed. after 24 hours, lost data, need rebuild TiCDC task.
It seems like the issue  https://github.com/pingcap/tiflow/issues/10682. The PR just fix add index and drop index. 
Please fix the issue caused by modifying the column

### What did you expect to see?

TiCDC normal

### What did you see instead?

TiCDC failed

### Versions of the cluster

Upstream TiDB cluster version (execute `SELECT tidb_version();` in a MySQL client):

```console
v6.5.3
```

Upstream TiKV version (execute `tikv-server --version`):

```console
v6.5.3
```

TiCDC version (execute `cdc version`):

```console
v6.5.3
```
Linked issue comments: user: login: wk989898
created_at: 2025-04-01T06:24:00Z
body: You can find troubleshooting in [doc](https://docs.pingcap.com/tidb/stable/troubleshoot-ticdc/#when-the-downstream-of-a-changefeed-is-a-database-similar-to-mysql-and-ticdc-executes-a-time-consuming-ddl-statement-all-other-changefeeds-are-blocked-what-should-i-do).
author_association: COLLABORATOR
user: login: kennytm
created_at: 2025-04-01T08:04:42Z
body: @wk989898 No, "stop changefeed → wait until MODIFY COLUMN to complete → edit `ignore-txn-start-ts` → restart changefeed" involves too much manual labor is very easy to get wrong and it is time wasting for the customer to sit watching when the DDL is complete. 

The thing is there is already an automatic solution #11476 using

https://github.com/pingcap/tiflow/blob/9ed3d76c5699f66050a2953b2d7b0f507a9ba3b3/cdc/sink/ddlsink/mysql/async_ddl.go#L35-L40

which has an explicit condition on `JOB_TYPE LIKE "add index%"`. It should not be difficult to extend this to cover other reorg-capable DDLs such as "modify column".


author_association: CONTRIBUTOR
user: login: flowbehappy
created_at: 2025-04-02T03:15:01Z
body: Let's do it both at the old and new arch @wk989898 
author_association: COLLABORATOR

Diff:
diff --git a/cdc/owner/ddl_sink.go b/cdc/owner/ddl_sink.go
index ecbe8c3444..44723aa392 100644
--- a/cdc/owner/ddl_sink.go
+++ b/cdc/owner/ddl_sink.go
@@ -244,7 +244,7 @@ func (s *ddlSinkImpl) writeDDLEvent(ctx context.Context, ddl *model.DDLEvent) er
 		if err = s.makeSinkReady(ctx); err == nil {
 			err = s.sink.WriteDDLEvent(ctx, ddl)
 			failpoint.Inject("InjectChangefeedDDLError", func() {
-				err = cerror.ErrExecDDLFailed.GenWithStackByArgs()
+				err = cerror.ErrExecDDLFailed.GenWithStackByArgs(ddl.Query)
 			})
 		}
 		if err != nil {
diff --git a/cdc/owner/ddl_sink_test.go b/cdc/owner/ddl_sink_test.go
index bb34086779..1a87a73f22 100644
--- a/cdc/owner/ddl_sink_test.go
+++ b/cdc/owner/ddl_sink_test.go
@@ -155,8 +155,8 @@ func TestExecDDLError(t *testing.T) {
 
 	ddlSink.run(ctx)
 
-	mSink.ddlError = cerror.ErrExecDDLFailed.GenWithStackByArgs()
 	ddl2 := &model.DDLEvent{CommitTs: 2, Query: "create table t2(id int)"}
+	mSink.ddlError = cerror.ErrExecDDLFailed.GenWithStackByArgs(ddl2.Query)
 	for {
 		done, err := ddlSink.emitDDLEvent(ctx, ddl2)
 		require.Nil(t, err)
diff --git a/cdc/sink/ddlsink/mysql/async_ddl.go b/cdc/sink/ddlsink/mysql/async_ddl.go
index 5f8a1666ce..ca3f63d79f 100644
--- a/cdc/sink/ddlsink/mysql/async_ddl.go
+++ b/cdc/sink/ddlsink/mysql/async_ddl.go
@@ -25,77 +25,15 @@ import (
 	"go.uber.org/zap"
 )
 
-const timeout = 5 * time.Second
-
-// TODO: Use the flollowing SQL to check the ddl job status after tidb optimize
-// the information_schema.ddl_jobs table. Ref: https://github.com/pingcap/tidb/issues/55725
-//
-// SELECT JOB_ID, JOB_TYPE, SCHEMA_STATE, SCHEMA_ID, TABLE_ID, STATE, QUERY
-// FROM information_schema.ddl_jobs
 var checkRunningAddIndexSQL = `
-ADMIN SHOW DDL JOBS 1
+SELECT JOB_ID, JOB_TYPE, SCHEMA_STATE, SCHEMA_ID, TABLE_ID, STATE, QUERY
+FROM information_schema.ddl_jobs
 WHERE DB_NAME = "%s"
     AND TABLE_NAME = "%s"
     AND JOB_TYPE LIKE "add index%%"
     AND (STATE = "running" OR STATE = "queueing");
 `
 
-func (m *DDLSink) shouldAsyncExecDDL(ddl *model.DDLEvent) bool {
-	return m.cfg.IsTiDB && ddl.Type == timodel.ActionAddIndex
-}
-
-// asyncExecDDL executes ddl in async mode.
-// this function only works in TiDB, because TiDB will save ddl jobs
-// and execute them asynchronously even if ticdc crashed.
-func (m *DDLSink) asyncExecDDL(ctx context.Context, ddl *model.DDLEvent) error {
-	done := make(chan error, 1)
-	// Use a longer timeout to ensure the add index ddl is sent to tidb before executing the next ddl.
-	tick := time.NewTimer(10 * time.Second)
-	defer tick.Stop()
-	log.Info("async exec add index ddl start",
-		zap.String("changefeedID", m.id.String()),
-		zap.Uint64("commitTs", ddl.CommitTs),
-		zap.String("ddl", ddl.Query))
-	go func() {
-		if err := m.execDDLWithMaxRetries(ctx, ddl); err != nil {
-			log.Error("async exec add index ddl failed",
-				zap.String("changefeedID", m.id.String()),
-				zap.Uint64("commitTs", ddl.CommitTs),
-				zap.String("ddl", ddl.Query))
-			done <- err
-			return
-		}
-		log.Info("async exec add index ddl done",
-			zap.String("changefeedID", m.id.String()),
-			zap.Uint64("commitTs", ddl.CommitTs),
-			zap.String("ddl", ddl.Query))
-		done <- nil
-	}()
-
-	select {
-	case <-ctx.Done():
-		// if the ddl is canceled, we just return nil, if the ddl is not received by tidb,
-		// the downstream ddl is lost, because the checkpoint ts is forwarded.
-		log.Info("async add index ddl exits as canceled",
-			zap.String("changefeedID", m.id.String()),
-			zap.Uint64("commitTs", ddl.CommitTs),
-			zap.String("ddl", ddl.Query))
-		return nil
-	case err := <-done:
-		// if the ddl is executed within 2 seconds, we just return the result to the caller.
-		return err
-	case <-tick.C:
-		// if the ddl is still running, we just return nil,
-		// then if the ddl is failed, the downstream ddl is lost.
-		// because the checkpoint ts is forwarded.
-		log.Info("async add index ddl is still running",
-			zap.String("changefeedID", m.id.String()),
-			zap.Uint64("commitTs", ddl.CommitTs),
-			zap.String("ddl", ddl.Query))
-		return nil
-	}
-}
-
 func (m *DDLSink) needWaitAsyncExecDone(t timodel.ActionType) bool {
 	if !m.cfg.IsTiDB {
 		return false
@@ -110,7 +48,7 @@ func (m *DDLSink) needWaitAsyncExecDone(t timodel.ActionType) bool {
 	}
 }
 
-// Should always wait for async ddl done before executing the next ddl.
+// wait for the previous asynchronous DDL to finish before executing the next ddl.
 func (m *DDLSink) waitAsynExecDone(ctx context.Context, ddl *model.DDLEvent) {
 	if !m.needWaitAsyncExecDone(ddl.Type) {
 		return
@@ -124,10 +62,11 @@ func (m *DDLSink) waitAsynExecDone(ctx context.Context, ddl *model.DDLEvent) {
 		tables[ddl.PreTableInfo.TableName] = struct{}{}
 	}
 
-	log.Debug("wait async exec ddl done",
+	log.Debug("Wait for the previous asynchronous DDL to finish",
 		zap.String("namespace", m.id.Namespace),
 		zap.String("changefeed", m.id.ID),
-		zap.Any("tables", tables),
+		zap.Any("tableInfo", ddl.TableInfo),
+		zap.Any("preTableInfo", ddl.PreTableInfo),
 		zap.Uint64("commitTs", ddl.CommitTs),
 		zap.String("ddl", ddl.Query))
 	if len(tables) == 0 || m.checkAsyncExecDDLDone(ctx, tables) {
@@ -150,7 +89,7 @@ func (m *DDLSink) waitAsynExecDone(ctx context.Context, ddl *model.DDLEvent) {
 }
 
 func (m *DDLSink) checkAsyncExecDDLDone(ctx context.Context, tables map[model.TableName]struct{}) bool {
-	ctx, cancel := context.WithTimeout(ctx, timeout)
+	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
 	defer cancel()
 	for table := range tables {
 		done := m.doCheck(ctx, table)
@@ -163,16 +102,6 @@ func (m *DDLSink) checkAsyncExecDDLDone(ctx context.Context, tables map[model.Ta
 
 func (m *DDLSink) doCheck(ctx context.Context, table model.TableName) (done bool) {
 	start := time.Now()
-	if v, ok := m.lastExecutedNormalDDLCache.Get(table); ok {
-		ddlType := v.(timodel.ActionType)
-		if ddlType == timodel.ActionAddIndex {
-			log.Panic("invalid ddl type in lastExecutedNormalDDLCache",
-				zap.String("namespace", m.id.Namespace),
-				zap.String("changefeed", m.id.ID),
-				zap.String("ddlType", ddlType.String()))
-		}
-		return true
-	}
 
 	rows, err := m.db.QueryContext(ctx, fmt.Sprintf(checkRunningAddIndexSQL, table.Schema, table.Table))
 	defer func() {
@@ -181,7 +110,7 @@ func (m *DDLSink) doCheck(ctx context.Context, table model.TableName) (done bool
 		}
 	}()
 	if err != nil {
-		log.Error("check async exec ddl failed",
+		log.Error("check previous asynchronous ddl failed",
 			zap.String("namespace", m.id.Namespace),
 			zap.String("changefeed", m.id.ID),
 			zap.Error(err))
@@ -189,7 +118,7 @@ func (m *DDLSink) doCheck(ctx context.Context, table model.TableName) (done bool
 	}
 	rets, err := export.GetSpecifiedColumnValuesAndClose(rows, "JOB_ID", "JOB_TYPE", "SCHEMA_STATE", "STATE")
 	if err != nil {
-		log.Error("check async exec ddl failed",
+		log.Error("check previous asynchronous ddl failed",
 			zap.String("namespace", m.id.Namespace),
 			zap.String("changefeed", m.id.ID),
 			zap.Error(err))
@@ -201,7 +130,7 @@ func (m *DDLSink) doCheck(ctx context.Context, table model.TableName) (done bool
 	}
 	ret := rets[0]
 	jobID, jobType, schemaState, state := ret[0], ret[1], ret[2], ret[3]
-	log.Info("async ddl is still running",
+	log.Info("The previous asynchronous ddl is still running",
 		zap.String("namespace", m.id.Namespace),
 		zap.String("changefeed", m.id.ID),
 		zap.Duration("checkDuration", time.Since(start)),
diff --git a/cdc/sink/ddlsink/mysql/async_ddl_test.go b/cdc/sink/ddlsink/mysql/async_ddl_test.go
index 3b302dff17..4ebdd051ad 100644
--- a/cdc/sink/ddlsink/mysql/async_ddl_test.go
+++ b/cdc/sink/ddlsink/mysql/async_ddl_test.go
@@ -81,22 +81,8 @@ func TestWaitAsynExecDone(t *testing.T) {
 	tables := make(map[model.TableName]struct{})
 	tables[table] = struct{}{}
 
-	// Test fast path, ddlSink.lastExecutedNormalDDLCache meet panic
-	ddlSink.lastExecutedNormalDDLCache.Add(table, timodel.ActionAddIndex)
-	require.Panics(t, func() {
-		ddlSink.checkAsyncExecDDLDone(ctx, tables)
-	})
-
-	// Test fast path, ddlSink.lastExecutedNormalDDLCache is hit
-	ddlSink.lastExecutedNormalDDLCache.Add(table, timodel.ActionCreateTable)
-	done := ddlSink.checkAsyncExecDDLDone(ctx, tables)
-	require.True(t, done)
-
-	// Clenup the cache, always check the async running state
-	ddlSink.lastExecutedNormalDDLCache.Remove(table)
-
 	// Test has running async ddl job
-	done = ddlSink.checkAsyncExecDDLDone(ctx, tables)
+	done := ddlSink.checkAsyncExecDDLDone(ctx, tables)
 	require.False(t, done)
 
 	// Test no running async ddl job
@@ -110,60 +96,6 @@ func TestWaitAsynExecDone(t *testing.T) {
 	ddlSink.Close()
 }
 
-func TestAsyncExecAddIndex(t *testing.T) {
-	ddlExecutionTime := time.Second * 15
-	dbConnFactory := pmysql.NewDBConnectionFactoryForTest()
-	dbConnFactory.SetStandardConnectionFactory(func(ctx context.Context, dsnStr string) (*sql.DB, error) {
-		db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
-		require.Nil(t, err)
-		mock.ExpectQuery("select tidb_version()").
-			WillReturnRows(sqlmock.NewRows([]string{"tidb_version()"}).AddRow("5.7.25-TiDB-v4.0.0-beta-191-ga1b3e3b"))
-		mock.ExpectQuery("select tidb_version()").WillReturnError(&dmysql.MySQLError{
-			Number:  1305,
-			Message: "FUNCTION test.tidb_version does not exist",
-		})
-		mock.ExpectBegin()
-		mock.ExpectExec("USE `test`;").
-			WillReturnResult(sqlmock.NewResult(1, 1))
-		mock.ExpectExec("Create index idx1 on test.t1(a)").
-			WillDelayFor(ddlExecutionTime).
-			WillReturnResult(sqlmock.NewResult(1, 1))
-		mock.ExpectCommit()
-		mock.ExpectClose()
-		return db, nil
-	})
-	GetDBConnImpl = dbConnFactory
-
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-	changefeed := "test-changefeed"
-	sinkURI, err := url.Parse("mysql://127.0.0.1:4000")
-	require.Nil(t, err)
-	rc := config.GetDefaultReplicaConfig()
-	sink, err := NewDDLSink(ctx, model.DefaultChangeFeedID(changefeed), sinkURI, rc)
-
-	require.Nil(t, err)
-
-	ddl1 := &model.DDLEvent{
-		StartTs:  1000,
-		CommitTs: 1010,
-		TableInfo: &model.TableInfo{
-			TableName: model.TableName{
-				Schema: "test",
-				Table:  "t1",
-			},
-		},
-		Type:  timodel.ActionAddIndex,
-		Query: "Create index idx1 on test.t1(a)",
-	}
-	start := time.Now()
-	err = sink.WriteDDLEvent(ctx, ddl1)
-	require.Nil(t, err)
-	require.True(t, time.Since(start) < ddlExecutionTime)
-	require.True(t, time.Since(start) >= 10*time.Second)
-	sink.Close()
-}
-
 func TestNeedWaitAsyncExecDone(t *testing.T) {
 	sink := &DDLSink{
 		cfg: &pmysql.Config{
diff --git a/cdc/sink/ddlsink/mysql/mysql_ddl_sink.go b/cdc/sink/ddlsink/mysql/mysql_ddl_sink.go
index 8d6c63153d..66b5734a5d 100644
--- a/cdc/sink/ddlsink/mysql/mysql_ddl_sink.go
+++ b/cdc/sink/ddlsink/mysql/mysql_ddl_sink.go
@@ -21,7 +21,7 @@ import (
 	"time"
 
 	"github.com/coreos/go-semver/semver"
-	lru "github.com/hashicorp/golang-lru"
+	"github.com/go-sql-driver/mysql"
 	"github.com/pingcap/failpoint"
 	"github.com/pingcap/log"
 	"github.com/pingcap/tidb/br/pkg/version"
@@ -43,9 +43,6 @@ import (
 const (
 	defaultDDLMaxRetry uint64 = 20
 
-	// networkDriftDuration is used to construct a context timeout for database operations.
-	networkDriftDuration = 5 * time.Second
-
 	defaultSupportVectorVersion = "8.4.0"
 )
 
@@ -67,11 +64,6 @@ type DDLSink struct {
 	// We use it to record the DDL count.
 	statistics *metrics.Statistics
 
-	// lastExecutedNormalDDLCache is a fast path to check whether aync DDL of a table
-	// is running in downstream.
-	// map: model.TableName -> timodel.ActionType
-	lastExecutedNormalDDLCache *lru.Cache
-
 	needFormat bool
 }
 
@@ -105,18 +97,12 @@ func NewDDLSink(
 		return nil, err
 	}
 
-	lruCache, err := lru.New(1024)
-	if err != nil {
-		return nil, err
-	}
-
 	m := &DDLSink{
-		id:                         changefeedID,
-		db:                         db,
-		cfg:                        cfg,
-		statistics:                 metrics.NewStatistics(changefeedID, sink.TxnSink),
-		lastExecutedNormalDDLCache: lruCache,
-		needFormat:                 needFormatDDL(db, cfg),
+		id:         changefeedID,
+		db:         db,
+		cfg:        cfg,
+		statistics: metrics.NewStatistics(changefeedID, sink.TxnSink),
+		needFormat: needFormatDDL(db, cfg),
 	}
 
 	log.Info("MySQL DDL sink is created",
@@ -129,19 +115,18 @@ func NewDDLSink(
 func (m *DDLSink) WriteDDLEvent(ctx context.Context, ddl *model.DDLEvent) error {
 	m.waitAsynExecDone(ctx, ddl)
 
-	if m.shouldAsyncExecDDL(ddl) {
-		m.lastExecutedNormalDDLCache.Remove(ddl.TableInfo.TableName)
-		return m.asyncExecDDL(ctx, ddl)
-	}
-
 	if err := m.execDDLWithMaxRetries(ctx, ddl); err != nil {
 		return errors.Trace(err)
 	}
-	m.lastExecutedNormalDDLCache.Add(ddl.TableInfo.TableName, ddl.Type)
 	return nil
 }
 
+// execDDLWithMaxRetries will retry executing DDL statements.
+// When a DDL execution takes a long time and an invalid connection error occurs.
+// If the downstream is TiDB, it will query the DDL and wait until it finishes.
+// For 'add index' ddl, it will return immediately without waiting and will query it during the next DDL execution.
 func (m *DDLSink) execDDLWithMaxRetries(ctx context.Context, ddl *model.DDLEvent) error {
+	ddlCreateTime := getDDLCreateTime(ctx, m.db)
 	return retry.Do(ctx, func() error {
 		err := m.statistics.RecordDDLExecution(func() error { return m.execDDL(ctx, ddl) })
 		if err != nil {
@@ -155,6 +140,11 @@ func (m *DDLSink) execDDLWithMaxRetries(ctx context.Context, ddl *model.DDLEvent
 				// If the error is ignorable, we will ignore the error directly.
 				return nil
 			}
+			if m.cfg.IsTiDB && ddlCreateTime != "" && errors.Cause(err) == mysql.ErrInvalidConn {
+				log.Warn("Wait the asynchronous ddl to synchronize", zap.String("ddl", ddl.Query), zap.String("ddlCreateTime", ddlCreateTime),
+					zap.String("readTimeout", m.cfg.ReadTimeout), zap.Error(err))
+				return m.waitDDLDone(ctx, ddl, ddlCreateTime)
+			}
 			log.Warn("Execute DDL with error, retry later",
 				zap.Uint64("startTs", ddl.StartTs), zap.String("ddl", ddl.Query),
 				zap.String("namespace", m.id.Namespace),
@@ -169,39 +159,8 @@ func (m *DDLSink) execDDLWithMaxRetries(ctx context.Context, ddl *model.DDLEvent
 		retry.WithIsRetryableErr(errorutil.IsRetryableDDLError))
 }
 
-// isReorgOrPartitionDDL returns true if given ddl type is reorg ddl or
-// partition ddl.
-func isReorgOrPartitionDDL(t timodel.ActionType) bool {
-	// partition related ddl
-	return t == timodel.ActionAddTablePartition ||
-		t == timodel.ActionExchangeTablePartition ||
-		t == timodel.ActionReorganizePartition ||
-		// reorg ddls
-		t == timodel.ActionAddPrimaryKey ||
-		t == timodel.ActionAddIndex ||
-		t == timodel.ActionModifyColumn ||
-		// following ddls can be fast when the downstream is TiDB, we must
-		// still take them into consideration to ensure compatibility with all
-		// MySQL-compatible databases.
-		t == timodel.ActionAddColumn ||
-		t == timodel.ActionAddColumns ||
-		t == timodel.ActionDropColumn ||
-		t == timodel.ActionDropColumns
-}
-
 func (m *DDLSink) execDDL(pctx context.Context, ddl *model.DDLEvent) error {
 	ctx := pctx
-	// When executing Reorg and Partition DDLs in TiDB, there is no timeout
-	// mechanism by default. Instead, the system will wait for the DDL operation
-	// to be executed or completed before proceeding.
-	if !isReorgOrPartitionDDL(ddl.Type) {
-		writeTimeout, _ := time.ParseDuration(m.cfg.WriteTimeout)
-		writeTimeout += networkDriftDuration
-		var cancelFunc func()
-		ctx, cancelFunc = context.WithTimeout(pctx, writeTimeout)
-		defer cancelFunc()
-	}
-
 	shouldSwitchDB := needSwitchDB(ddl)
 
 	// Convert vector type to string type for unsupport database
@@ -264,8 +223,6 @@ func (m *DDLSink) execDDL(pctx context.Context, ddl *model.DDLEvent) error {
 	}
 
 	if err = tx.Commit(); err != nil {
-		log.Error("Failed to exec DDL", zap.String("namespace", m.id.Namespace), zap.String("changefeed", m.id.ID),
-			zap.Duration("duration", time.Since(start)), zap.String("sql", ddl.Query), zap.Error(err))
 		return errors.WrapError(errors.ErrMySQLTxnError, errors.WithMessage(err, fmt.Sprintf("Query info: %s; ", ddl.Query)))
 	}
 
@@ -275,6 +232,65 @@ func (m *DDLSink) execDDL(pctx context.Context, ddl *model.DDLEvent) error {
 	return nil
 }
 
+func (m *DDLSink) waitDDLDone(ctx context.Context, ddl *model.DDLEvent, ddlCreateTime string) error {
+	ticker := time.NewTicker(5 * time.Second)
+	ticker1 := time.NewTicker(10 * time.Minute)
+	defer ticker.Stop()
+	defer ticker1.Stop()
+	for {
+		state, err := getDDLStateFromTiDB(ctx, m.db, ddl.Query, ddlCreateTime)
+		if err != nil {
+			log.Error("Error when getting DDL state from TiDB", zap.Error(err))
+		}
+		switch state {
+		case timodel.JobStateDone, timodel.JobStateSynced:
+			log.Info("DDL replicate success", zap.String("ddl", ddl.Query), zap.String("ddlCreateTime", ddlCreateTime))
+			return nil
+		case timodel.JobStateCancelled, timodel.JobStateRollingback, timodel.JobStateRollbackDone, timodel.JobStateCancelling:
+			return errors.ErrExecDDLFailed.GenWithStackByArgs(ddl.Query)
+		case timodel.JobStateRunning, timodel.JobStateQueueing:
+			switch ddl.Type {
+			// returned immediately if not block dml
+			case timodel.ActionAddIndex:
+				log.Info("DDL is running downstream", zap.String("ddl", ddl.Query), zap.String("ddlCreateTime", ddlCreateTime), zap.Any("ddlState", state))
+				return nil
+			}
+		default:
+			log.Warn("Unexpected DDL state, may not be found downstream", zap.String("ddl", ddl.Query), zap.String("ddlCreateTime", ddlCreateTime), zap.Any("ddlState", state))
+			return errors.ErrDDLStateNotFound.GenWithStackByArgs(state)
+		}
+
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-ticker.C:
+		case <-ticker1.C:
+			log.Info("DDL is still running downstream, it blocks other DDL or DML events", zap.String("ddl", ddl.Query), zap.String("ddlCreateTime", ddlCreateTime))
+		}
+	}
+}
+
+// WriteCheckpointTs does nothing.
+func (m *DDLSink) WriteCheckpointTs(_ context.Context, _ uint64, _ []*model.TableInfo) error {
+	// Only for RowSink for now.
+	return nil
+}
+
+// Close closes the database connection.
+func (m *DDLSink) Close() {
+	if m.statistics != nil {
+		m.statistics.Close()
+	}
+	if m.db != nil {
+		if err := m.db.Close(); err != nil {
+			log.Warn("MySQL ddl sink close db wit error",
+				zap.String("namespace", m.id.Namespace),
+				zap.String("changefeed", m.id.ID),
+				zap.Error(err))
+		}
+	}
+}
+
 func needSwitchDB(ddl *model.DDLEvent) bool {
 	if len(ddl.TableInfo.TableName.Schema) == 0 {
 		return false
@@ -306,23 +322,53 @@ func needFormatDDL(db *sql.DB, cfg *pmysql.Config) bool {
 	return false
 }
 
-// WriteCheckpointTs does nothing.
-func (m *DDLSink) WriteCheckpointTs(_ context.Context, _ uint64, _ []*model.TableInfo) error {
-	// Only for RowSink for now.
-	return nil
+func getDDLCreateTime(ctx context.Context, db *sql.DB) string {
+	ddlCreateTime := "" // default when scan failed
+	row, err := db.QueryContext(ctx, "BEGIN; SET @ticdc_ts := TIDB_PARSE_TSO(@@tidb_current_ts); ROLLBACK; SELECT @ticdc_ts; SET @ticdc_ts=NULL;")
+	if err != nil {
+		log.Warn("selecting tidb current timestamp failed", zap.Error(err))
+	} else {
+		for row.Next() {
+			err = row.Scan(&ddlCreateTime)
+			if err != nil {
+				log.Warn("getting ddlCreateTime failed", zap.Error(err))
+			}
+		}
+		//nolint:sqlclosecheck
+		_ = row.Close()
+		_ = row.Err()
+	}
+	return ddlCreateTime
 }
 
-// Close closes the database connection.
-func (m *DDLSink) Close() {
-	if m.statistics != nil {
-		m.statistics.Close()
+// getDDLStateFromTiDB retrieves the ddl job status of the ddl query from downstream tidb based on the ddl query and the approximate ddl create time.
+func getDDLStateFromTiDB(ctx context.Context, db *sql.DB, ddl string, createTime string) (timodel.JobState, error) {
+	// ddlCreateTime and createTime are both based on UTC timezone of downstream
+	showJobs := fmt.Sprintf(`SELECT JOB_ID, JOB_TYPE, SCHEMA_STATE, SCHEMA_ID, TABLE_ID, STATE, QUERY FROM information_schema.ddl_jobs 
+	WHERE CREATE_TIME >= "%s" AND QUERY = "%s";`, createTime, ddl)
+	//nolint:rowserrcheck
+	jobsRows, err := db.QueryContext(ctx, showJobs)
+	if err != nil {
+		return timodel.JobStateNone, err
 	}
-	if m.db != nil {
-		if err := m.db.Close(); err != nil {
-			log.Warn("MySQL ddl sink close db wit error",
-				zap.String("namespace", m.id.Namespace),
-				zap.String("changefeed", m.id.ID),
-				zap.Error(err))
-		}
+
+	var jobsResults [][]string
+	jobsResults, err = export.GetSpecifiedColumnValuesAndClose(jobsRows, "QUERY", "STATE", "JOB_ID", "JOB_TYPE", "SCHEMA_STATE")
+	if err != nil {
+		return timodel.JobStateNone, err
+	}
+	if len(jobsResults) > 0 {
+		result := jobsResults[0]
+		state, jobID, jobType, schemaState := result[1], result[2], result[3], result[4]
+		log.Debug("Find ddl state in downstream",
+			zap.String("jobID", jobID),
+			zap.String("jobType", jobType),
+			zap.String("schemaState", schemaState),
+			zap.String("ddl", ddl),
+			zap.String("state", state),
+			zap.Any("jobsResults", jobsResults),
+		)
+		return timodel.StrToJobState(result[1]), nil
 	}
+	return timodel.JobStateNone, nil
 }
diff --git a/errors.toml b/errors.toml
index 32f6c0a8fc..a79612d500 100755
--- a/errors.toml
+++ b/errors.toml
@@ -226,6 +226,11 @@ error = '''
 cannot find mysql.tidb_ddl_job schema
 '''
 
+["CDC:ErrDDLStateNotFound"]
+error = '''
+DDL state not found %s
+'''
+
 ["CDC:ErrDDLUnsupportType"]
 error = '''
 unsupport ddl type %s, query %s
@@ -323,7 +328,7 @@ exchange partition failed, %s
 
 ["CDC:ErrExecDDLFailed"]
 error = '''
-exec DDL failed
+exec DDL failed %s
 '''
 
 ["CDC:ErrExpressionColumnNotFound"]
diff --git a/pkg/errors/cdc_errors.go b/pkg/errors/cdc_errors.go
index 06f9977f90..87fe08c914 100644
--- a/pkg/errors/cdc_errors.go
+++ b/pkg/errors/cdc_errors.go
@@ -171,9 +171,13 @@ var (
 
 	// sink related errors
 	ErrExecDDLFailed = errors.Normalize(
-		"exec DDL failed",
+		"exec DDL failed %s",
 		errors.RFCCodeText("CDC:ErrExecDDLFailed"),
 	)
+	ErrDDLStateNotFound = errors.Normalize(
+		"DDL state not found %s",
+		errors.RFCCodeText("CDC:ErrDDLStateNotFound"),
+	)
 	ErrKafkaSendMessage = errors.Normalize(
 		"kafka send message failed",
 		errors.RFCCodeText("CDC:ErrKafkaSendMessage"),
diff --git a/tests/integration_tests/ddl_wait/conf/diff_config.toml b/tests/integration_tests/ddl_wait/conf/diff_config.toml
new file mode 100644
index 0000000000..6435d3b89b
--- /dev/null
+++ b/tests/integration_tests/ddl_wait/conf/diff_config.toml
@@ -0,0 +1,28 @@
+# diff Configuration.
+check-thread-count = 4
+
+export-fix-sql = true
+
+check-struct-only = false
+
+[task]
+output-dir = "/tmp/tidb_cdc_test/ddl_wait/sync_diff/output"
+
+source-instances = ["mysql1"]
+
+target-instance = "tidb0"
+
+target-check-tables = ["test.*"]
+
+[data-sources]
+[data-sources.mysql1]
+host = "127.0.0.1"
+port = 4000
+user = "root"
+password = ""
+
+[data-sources.tidb0]
+host = "127.0.0.1"
+port = 3306
+user = "root"
+password = ""
diff --git a/tests/integration_tests/ddl_wait/run.sh b/tests/integration_tests/ddl_wait/run.sh
new file mode 100755
index 0000000000..f9b43e4813
--- /dev/null
+++ b/tests/integration_tests/ddl_wait/run.sh
@@ -0,0 +1,46 @@
+#!/bin/bash
+
+set -eu
+
+CUR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+source $CUR/../_utils/test_prepare
+WORK_DIR=$OUT_DIR/$TEST_NAME
+CDC_BINARY=cdc.test
+SINK_TYPE=$1
+
+# This test simulates DDL operations that take a long time.
+# TiCDC blocks DDL operations until its state is not running, except for adding indexes.
+# TiCDC also checks add index ddl state before execute a new DDL.
+function run() {
+	rm -rf $WORK_DIR && mkdir -p $WORK_DIR
+
+	start_tidb_cluster --workdir $WORK_DIR
+
+	cd $WORK_DIR
+
+	run_cdc_server --workdir $WORK_DIR --binary $CDC_BINARY
+
+	cd $CUR
+	GO111MODULE=on go run test.go
+
+	TOPIC_NAME="ticdc-ddl-wait-test-$RANDOM"
+	SINK_URI="mysql://root@127.0.0.1:3306/?read-timeout=300ms"
+
+	changefeed_id="ddl-wait"
+	run_cdc_cli changefeed create --sink-uri="$SINK_URI" -c=${changefeed_id}
+
+	run_sql "alter table test.t modify column col decimal(30,10);"
+	run_sql "alter table test.t add index (col);"
+	run_sql "create table test.finish_mark (a int primary key);"
+	check_table_exists test.finish_mark ${DOWN_TIDB_HOST} ${DOWN_TIDB_PORT} 300
+	# make sure all tables are equal in upstream and downstream
+	check_sync_diff $WORK_DIR $CUR/conf/diff_config.toml 180
+	check_logs_contains $WORK_DIR "DDL replicate success"
+	check_logs_contains $WORK_DIR "DDL is running downstream"
+	cleanup_process $CDC_BINARY
+}
+
+trap stop_tidb_cluster EXIT
+run $*
+check_logs $WORK_DIR
+echo "[$(date)] <<<<<< run test case $TEST_NAME success! >>>>>>"
diff --git a/tests/integration_tests/ddl_wait/test.go b/tests/integration_tests/ddl_wait/test.go
new file mode 100644
index 0000000000..204e385a03
--- /dev/null
+++ b/tests/integration_tests/ddl_wait/test.go
@@ -0,0 +1,97 @@
+// 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 main
+
+import (
+	"database/sql"
+	"fmt"
+	"log"
+	"os"
+	"sync"
+
+	_ "github.com/go-sql-driver/mysql"
+)
+
+func main() {
+	upHost := GetEnvDefault("UP_TIDB_HOST", "127.0.0.1")
+	upPort := GetEnvDefault("UP_TIDB_PORT", "4000")
+	downHost := GetEnvDefault("UP_TIDB_HOST", "127.0.0.1")
+	downPort := GetEnvDefault("UP_TIDB_PORT", "3306")
+
+	var wg sync.WaitGroup
+	wg.Add(2)
+	go func() {
+		defer wg.Done()
+		run(upHost, upPort)
+	}()
+	go func() {
+		defer wg.Done()
+		run(downHost, downPort)
+	}()
+	wg.Wait()
+}
+
+func run(host string, port string) {
+	dsn := fmt.Sprintf("root@tcp(%s:%s)/", host, port)
+	db, err := sql.Open("mysql", dsn)
+	if err != nil {
+		log.Fatal("open db failed:", dsn, ", err: ", err)
+	}
+	defer db.Close()
+
+	if err = db.Ping(); err != nil {
+		log.Fatal("ping db failed:", dsn, ", err: ", err)
+	}
+	log.Println("connect to tidb success, dsn: ", dsn)
+
+	createTable := `create table if not exists test.t (
+		id int primary key,
+		col decimal(65,30)
+	);`
+	insertDML := `insert into test.t values (?, ?)`
+
+	concurrency := 200
+	maxRowCnt := 1000000
+	num := maxRowCnt / concurrency
+	db.SetMaxOpenConns(concurrency)
+
+	_, err = db.Exec(createTable)
+	if err != nil {
+		log.Fatal("create table failed:, err: ", err)
+	}
+
+	var wg sync.WaitGroup
+	for k := 0; k < concurrency; k++ {
+		wg.Add(1)
+		go func(k int) {
+			defer wg.Done()
+			for i := 0; i < num; i++ {
+				val := k*num + i
+				_, err = db.Exec(insertDML, val, val)
+				if err != nil {
+					log.Fatal("insert value failed:, err: ", err)
+				}
+			}
+		}(k)
+	}
+	wg.Wait()
+}
+
+func GetEnvDefault(key, defaultV string) string {
+	val, ok := os.LookupEnv(key)
+	if !ok {
+		return defaultV
+	}
+	return val
+}
diff --git a/tests/integration_tests/run_group.sh b/tests/integration_tests/run_group.sh
index 0905dc1562..f44d114243 100755
--- a/tests/integration_tests/run_group.sh
+++ b/tests/integration_tests/run_group.sh
@@ -73,7 +73,7 @@ groups=(
 	# G19
 	'changefeed_fast_fail batch_update_to_no_batch changefeed_resume_with_checkpoint_ts'
 	# G20
-	'tidb_mysql_test ddl_reentrant multi_cdc_cluster'
+	'tidb_mysql_test ddl_reentrant multi_cdc_cluster ddl_wait'
 	# G21
 	'bank kill_owner_with_ddl owner_remove_table_error'
 )


Reviews:
user: login: hongyunyan
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T08:39:35Z
user: login: hongyunyan
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T08:42:07Z
user: login: hongyunyan
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T02:21:52Z
user: login: hongyunyan
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T02:23:44Z
user: login: hongyunyan
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T05:57:47Z
user: login: hongyunyan
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T06:00:10Z
user: login: hongyunyan
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T06:01:48Z
user: login: hongyunyan
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T06:04:35Z
user: login: wk989898
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T09:20:00Z
user: login: hongyunyan
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T04:56:37Z
user: login: hongyunyan
body: LGTM
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-11T12:04:02Z
user: login: asddongmen
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T07:24:02Z

Review comments:
diff_hunk: @@ -306,23 +357,116 @@ func needFormatDDL(db *sql.DB, cfg *pmysql.Config) bool {
 	return false
 }
 
-// WriteCheckpointTs does nothing.
-func (m *DDLSink) WriteCheckpointTs(_ context.Context, _ uint64, _ []*model.TableInfo) error {
-	// Only for RowSink for now.
-	return nil
+func getDDLCreateTime(ctx context.Context, db *sql.DB) int64 {
+	var ddlCreateTime int64 = -1 // default when scan failed
+	row, err := db.QueryContext(ctx, "SELECT UNIX_TIMESTAMP()")
+	if err != nil {
+		log.Warn("selecting unix timestamp failed", zap.Error(err))
+	} else {
+		for row.Next() {
+			err = row.Scan(&ddlCreateTime)
+			if err != nil {
+				log.Warn("getting ddlCreateTime failed", zap.Error(err))
+			}
+		}
+		//nolint:sqlclosecheck
+		_ = row.Close()
+		_ = row.Err()
+	}
+	return ddlCreateTime
 }
 
-// Close closes the database connection.
-func (m *DDLSink) Close() {
-	if m.statistics != nil {
-		m.statistics.Close()
-	}
-	if m.db != nil {
-		if err := m.db.Close(); err != nil {
-			log.Warn("MySQL ddl sink close db wit error",
-				zap.String("namespace", m.id.Namespace),
-				zap.String("changefeed", m.id.ID),
-				zap.Error(err))
+// getDDLStatusFromTiDB retrieves the synchronizing status of DDL from TiDB
+func getDDLStatusFromTiDB(ctx context.Context, db *sql.DB, ddl string, createTime int64) (string, error) {
+	linesOfRows := 10
+	rowNum := linesOfRows
+	rowOffset := 0
+	queryMap := make(map[int]string)
+
+	for {
+		// every attempt try 10 history jobs
+		showJobs := fmt.Sprintf("ADMIN SHOW DDL JOBS %d", rowNum)
path: cdc/sink/ddlsink/mysql/mysql_ddl_sink.go
user: login: hongyunyan
body: please use information_schema.ddl_jobs to get these infos instead of ADMIN SHOW DDL JOBS.
ref // TODO: Use the flollowing SQL to check the ddl job status after tidb optimize
// the information_schema.ddl_jobs table. Ref: https://github.com/pingcap/tidb/issues/55725
//
// SELECT JOB_ID, JOB_TYPE, SCHEMA_STATE, SCHEMA_ID, TABLE_ID, STATE, QUERY
// FROM information_schema.ddl_jobs
created_at: 2025-04-09T08:39:35Z
author_association: COLLABORATOR
diff_hunk: @@ -134,7 +136,35 @@ func (m *DDLSink) WriteDDLEvent(ctx context.Context, ddl *model.DDLEvent) error
 		return m.asyncExecDDL(ctx, ddl)
 	}
 
+	ddlCreateTime := getDDLCreateTime(ctx, m.db)
 	if err := m.execDDLWithMaxRetries(ctx, ddl); err != nil {
+		if m.cfg.IsTiDB && ddlCreateTime != -1 && errors.Cause(err) == mysql.ErrInvalidConn {
path: cdc/sink/ddlsink/mysql/mysql_ddl_sink.go
user: login: hongyunyan
body: It's better to extract a function for these logic.
created_at: 2025-04-09T08:42:07Z
author_association: COLLABORATOR
diff_hunk: @@ -306,23 +331,123 @@ func needFormatDDL(db *sql.DB, cfg *pmysql.Config) bool {
 	return false
 }
 
-// WriteCheckpointTs does nothing.
-func (m *DDLSink) WriteCheckpointTs(_ context.Context, _ uint64, _ []*model.TableInfo) error {
-	// Only for RowSink for now.
-	return nil
+func getDDLCreateTime(ctx context.Context, db *sql.DB) int64 {
+	var ddlCreateTime int64 = -1 // default when scan failed
+	row, err := db.QueryContext(ctx, "SELECT UNIX_TIMESTAMP()")
+	if err != nil {
+		log.Warn("selecting unix timestamp failed", zap.Error(err))
+	} else {
+		for row.Next() {
+			err = row.Scan(&ddlCreateTime)
+			if err != nil {
+				log.Warn("getting ddlCreateTime failed", zap.Error(err))
+			}
+		}
+		//nolint:sqlclosecheck
+		_ = row.Close()
+		_ = row.Err()
+	}
+	return ddlCreateTime
 }
 
-// Close closes the database connection.
-func (m *DDLSink) Close() {
-	if m.statistics != nil {
-		m.statistics.Close()
-	}
-	if m.db != nil {
-		if err := m.db.Close(); err != nil {
-			log.Warn("MySQL ddl sink close db wit error",
-				zap.String("namespace", m.id.Namespace),
-				zap.String("changefeed", m.id.ID),
-				zap.Error(err))
+var checkRunningSQL = `
path: cdc/sink/ddlsink/mysql/mysql_ddl_sink.go
user: login: hongyunyan
body: we can update the sql to selete the jobs whose create_time > ddlCreateTime, instead of loop the rows to check.

Besides, the sql can also add the clause like `AND JOB_TYPE LIKE "add index%%`
created_at: 2025-04-10T02:21:52Z
author_association: COLLABORATOR
diff_hunk: @@ -306,23 +331,123 @@ func needFormatDDL(db *sql.DB, cfg *pmysql.Config) bool {
 	return false
 }
 
-// WriteCheckpointTs does nothing.
-func (m *DDLSink) WriteCheckpointTs(_ context.Context, _ uint64, _ []*model.TableInfo) error {
-	// Only for RowSink for now.
-	return nil
+func getDDLCreateTime(ctx context.Context, db *sql.DB) int64 {
+	var ddlCreateTime int64 = -1 // default when scan failed
+	row, err := db.QueryContext(ctx, "SELECT UNIX_TIMESTAMP()")
+	if err != nil {
+		log.Warn("selecting unix timestamp failed", zap.Error(err))
+	} else {
+		for row.Next() {
+			err = row.Scan(&ddlCreateTime)
+			if err != nil {
+				log.Warn("getting ddlCreateTime failed", zap.Error(err))
+			}
+		}
+		//nolint:sqlclosecheck
+		_ = row.Close()
+		_ = row.Err()
+	}
+	return ddlCreateTime
 }
 
-// Close closes the database connection.
-func (m *DDLSink) Close() {
-	if m.statistics != nil {
-		m.statistics.Close()
-	}
-	if m.db != nil {
-		if err := m.db.Close(); err != nil {
-			log.Warn("MySQL ddl sink close db wit error",
-				zap.String("namespace", m.id.Namespace),
-				zap.String("changefeed", m.id.ID),
-				zap.Error(err))
+var checkRunningSQL = `
+SELECT JOB_ID, JOB_TYPE, SCHEMA_STATE, SCHEMA_ID, TABLE_ID, STATE, QUERY
+FROM information_schema.ddl_jobs
+WHERE STATE = "running" OR STATE = "queueing"
+LIMIT %s
+`
+
+// getDDLStatusFromTiDB retrieves the synchronizing status of DDL from TiDB
path: cdc/sink/ddlsink/mysql/mysql_ddl_sink.go
user: login: hongyunyan
body: make the comments more clean like `the function is select the ddl job which satistifed xxxxxxxx`. It's a very complex function, so we must need a clear comment to show what it is doing.
created_at: 2025-04-10T02:23:44Z
author_association: COLLABORATOR
diff_hunk: @@ -275,6 +251,54 @@ func (m *DDLSink) execDDL(pctx context.Context, ddl *model.DDLEvent) error {
 	return nil
 }
 
+func (m *DDLSink) waitDDLDone(ctx context.Context, ddl *model.DDLEvent, ddlCreateTime string) error {
+	ticker := time.NewTimer(30 * time.Second)
+	defer ticker.Stop()
+	for {
+		state, err1 := getDDLStateFromTiDB(ctx, m.db, ddl.Query, ddlCreateTime)
path: cdc/sink/ddlsink/mysql/mysql_ddl_sink.go
user: login: hongyunyan
body: ```suggestion
		state, err := getDDLStateFromTiDB(ctx, m.db, ddl.Query, ddlCreateTime)
```
created_at: 2025-04-10T05:57:47Z
author_association: COLLABORATOR
diff_hunk: @@ -306,23 +330,48 @@ func needFormatDDL(db *sql.DB, cfg *pmysql.Config) bool {
 	return false
 }
 
-// WriteCheckpointTs does nothing.
-func (m *DDLSink) WriteCheckpointTs(_ context.Context, _ uint64, _ []*model.TableInfo) error {
-	// Only for RowSink for now.
-	return nil
+func getDDLCreateTime(ctx context.Context, db *sql.DB) string {
+	ddlCreateTime := "" // default when scan failed
+	row, err := db.QueryContext(ctx, "SELECT UTC_TIMESTAMP()")
path: cdc/sink/ddlsink/mysql/mysql_ddl_sink.go
user: login: hongyunyan
body: I'm not sure whether the UTC_TIMESTAMP will be different from the tso by pd clock.
created_at: 2025-04-10T06:00:10Z
author_association: COLLABORATOR
diff_hunk: @@ -306,23 +330,48 @@ func needFormatDDL(db *sql.DB, cfg *pmysql.Config) bool {
 	return false
 }
 
-// WriteCheckpointTs does nothing.
-func (m *DDLSink) WriteCheckpointTs(_ context.Context, _ uint64, _ []*model.TableInfo) error {
-	// Only for RowSink for now.
-	return nil
+func getDDLCreateTime(ctx context.Context, db *sql.DB) string {
+	ddlCreateTime := "" // default when scan failed
+	row, err := db.QueryContext(ctx, "SELECT UTC_TIMESTAMP()")
+	if err != nil {
+		log.Warn("selecting unix timestamp failed", zap.Error(err))
+	} else {
+		for row.Next() {
+			err = row.Scan(&ddlCreateTime)
+			if err != nil {
+				log.Warn("getting ddlCreateTime failed", zap.Error(err))
+			}
+		}
+		//nolint:sqlclosecheck
+		_ = row.Close()
+		_ = row.Err()
+	}
+	return ddlCreateTime
 }
 
-// Close closes the database connection.
-func (m *DDLSink) Close() {
-	if m.statistics != nil {
-		m.statistics.Close()
+// getDDLStateFromTiDB retrieves the synchronizing status of DDL from TiDB
+// This function selects DDL jobs based on a provided timestamp, to identify downstream DDL changes applied within that timeframe.
+// We can identify the DDL statements that have been replicated downstream.
+func getDDLStateFromTiDB(ctx context.Context, db *sql.DB, ddl string, createTime string) (string, error) {
+	showJobs := fmt.Sprintf(`SELECT QUERY,STATE FROM information_schema.ddl_jobs WHERE CREATE_TIME > '%s' ORDER BY CREATE_TIME DESC;`, createTime)
+	//nolint:rowserrcheck
+	jobsRows, err := db.QueryContext(ctx, showJobs)
+	if err != nil {
+		return "", err
 	}
-	if m.db != nil {
-		if err := m.db.Close(); err != nil {
-			log.Warn("MySQL ddl sink close db wit error",
-				zap.String("namespace", m.id.Namespace),
-				zap.String("changefeed", m.id.ID),
-				zap.Error(err))
+
+	var jobsResults [][]string
+	jobsResults, err = export.GetSpecifiedColumnValuesAndClose(jobsRows, "QUERY", "STATE")
+	if err != nil {
+		return "", err
+	}
+	for i := 0; i < len(jobsResults); i++ {
path: cdc/sink/ddlsink/mysql/mysql_ddl_sink.go
user: login: hongyunyan
body: why don't we do that in sql statement like create time?
created_at: 2025-04-10T06:01:48Z
author_association: COLLABORATOR
diff_hunk: @@ -306,23 +330,48 @@ func needFormatDDL(db *sql.DB, cfg *pmysql.Config) bool {
 	return false
 }
 
-// WriteCheckpointTs does nothing.
-func (m *DDLSink) WriteCheckpointTs(_ context.Context, _ uint64, _ []*model.TableInfo) error {
-	// Only for RowSink for now.
-	return nil
+func getDDLCreateTime(ctx context.Context, db *sql.DB) string {
+	ddlCreateTime := "" // default when scan failed
+	row, err := db.QueryContext(ctx, "SELECT UTC_TIMESTAMP()")
+	if err != nil {
+		log.Warn("selecting unix timestamp failed", zap.Error(err))
+	} else {
+		for row.Next() {
+			err = row.Scan(&ddlCreateTime)
+			if err != nil {
+				log.Warn("getting ddlCreateTime failed", zap.Error(err))
+			}
+		}
+		//nolint:sqlclosecheck
+		_ = row.Close()
+		_ = row.Err()
+	}
+	return ddlCreateTime
 }
 
-// Close closes the database connection.
-func (m *DDLSink) Close() {
-	if m.statistics != nil {
-		m.statistics.Close()
+// getDDLStateFromTiDB retrieves the synchronizing status of DDL from TiDB
+// This function selects DDL jobs based on a provided timestamp, to identify downstream DDL changes applied within that timeframe.
+// We can identify the DDL statements that have been replicated downstream.
path: cdc/sink/ddlsink/mysql/mysql_ddl_sink.go
user: login: hongyunyan
body: ```suggestion
getDDLStateFromTiDB retrieves the ddl job status of the ddl query from downstream tidb based on the ddl query and the approximate ddl create time.
```
created_at: 2025-04-10T06:04:35Z
author_association: COLLABORATOR
diff_hunk: @@ -306,23 +330,48 @@ func needFormatDDL(db *sql.DB, cfg *pmysql.Config) bool {
 	return false
 }
 
-// WriteCheckpointTs does nothing.
-func (m *DDLSink) WriteCheckpointTs(_ context.Context, _ uint64, _ []*model.TableInfo) error {
-	// Only for RowSink for now.
-	return nil
+func getDDLCreateTime(ctx context.Context, db *sql.DB) string {
+	ddlCreateTime := "" // default when scan failed
+	row, err := db.QueryContext(ctx, "SELECT UTC_TIMESTAMP()")
path: cdc/sink/ddlsink/mysql/mysql_ddl_sink.go
user: login: wk989898
body: The `ddlCreateTime` is from downstream.
created_at: 2025-04-10T09:20:00Z
author_association: COLLABORATOR
diff_hunk: @@ -306,23 +327,52 @@ func needFormatDDL(db *sql.DB, cfg *pmysql.Config) bool {
 	return false
 }
 
-// WriteCheckpointTs does nothing.
-func (m *DDLSink) WriteCheckpointTs(_ context.Context, _ uint64, _ []*model.TableInfo) error {
-	// Only for RowSink for now.
-	return nil
+func getDDLCreateTime(ctx context.Context, db *sql.DB) string {
+	ddlCreateTime := "" // default when scan failed
+	row, err := db.QueryContext(ctx, "BEGIN; SET @ticdc_ts := TIDB_PARSE_TSO(@@tidb_current_ts); ROLLBACK; SELECT @ticdc_ts; SET @ticdc_ts=NULL;")
+	if err != nil {
+		log.Warn("selecting utc timestamp failed", zap.Error(err))
path: cdc/sink/ddlsink/mysql/mysql_ddl_sink.go
user: login: hongyunyan
body: ```suggestion
		log.Warn("selecting tidb current timestamp failed", zap.Error(err))
```
created_at: 2025-04-11T04:56:36Z
author_association: COLLABORATOR

PR comments:
user: login: ti-chi-bot[bot]
created_at: 2025-04-07T08:15:36Z
body: Skipping CI for Draft Pull Request.
If you want CI signal for your change, please convert it to an actual PR.
You can still manually trigger a test run with `/test all`
author_association: CONTRIBUTOR
user: login: wk989898
created_at: 2025-04-07T08:16:26Z
body: /test pull-cdc-integration-mysql-test
author_association: COLLABORATOR
user: login: asddongmen
created_at: 2025-04-14T03:02:40Z
body: Please complete the "Check List" part of this PR description. Specifically, remove the items that you didn't do and the redundant items (No code).
author_association: CONTRIBUTOR
user: login: asddongmen
created_at: 2025-04-14T03:09:32Z
body: Please conduct a manual test to ensure there are no performance issues when there are thousands of tables. I'm concerned about the execution time of the query: `SELECT JOB_ID, JOB_TYPE, SCHEMA_STATE, SCHEMA_ID, TABLE_ID, STATE, QUERY FROM information_schema.ddl_jobs`. 

author_association: CONTRIBUTOR
user: login: wk989898
created_at: 2025-04-15T04:30:54Z
body: > Please conduct a manual test to ensure there are no performance issues when there are thousands of tables. I'm concerned about the execution time of the query: `SELECT JOB_ID, JOB_TYPE, SCHEMA_STATE, SCHEMA_ID, TABLE_ID, STATE, QUERY FROM information_schema.ddl_jobs`.

I test it with v9.0.0-beta.1.pre-nightly version, and you could find more information in pingcap/tidb#55748
author_association: COLLABORATOR
user: login: wk989898
created_at: 2025-04-21T03:17:54Z
body: /retest
author_association: COLLABORATOR
user: login: ti-chi-bot[bot]
created_at: 2025-04-21T07:24:31Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/pingcap/tiflow/pull/12123#pullrequestreview-2780724211" title="Approved">asddongmen</a>*, *<a href="https://github.com/pingcap/tiflow/pull/12123#pullrequestreview-2760138446" title="Approved">hongyunyan</a>*

The full list of commands accepted by this bot can be found [here](https://prow.tidb.net/command-help?repo=pingcap%2Ftiflow).

The pull request process is described [here](https://book.prow.tidb.net/#/workflows/pr)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/pingcap/tiflow/blob/master/OWNERS)~~ [asddongmen,hongyunyan]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR
user: login: ti-chi-bot[bot]
created_at: 2025-04-21T07:24:32Z
body: [LGTM Timeline notifier]
---
Timeline:

- `2025-04-11 12:04:08.077966242 +0000 UTC m=+2430741.762202325`: :ballot_box_with_check: agreed by [hongyunyan](https://github.com/hongyunyan).
- `2025-04-21 07:24:31.983891754 +0000 UTC m=+254015.795682135`: :ballot_box_with_check: agreed by [asddongmen](https://github.com/asddongmen).
author_association: CONTRIBUTOR
user: login: wk989898
created_at: 2025-04-22T03:36:20Z
body: /test pull-engine-integration-test
author_association: COLLABORATOR
user: login: ti-chi-bot
created_at: 2025-12-12T08:58:53Z
body: In response to a cherrypick label: new pull request created to branch `release-8.5`: #12466.
But this PR has conflicts, please resolve them!
author_association: MEMBER