This is an automated email from the ASF dual-hosted git repository.

lvyanquan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink-cdc.git


The following commit(s) were added to refs/heads/master by this push:
     new 73a547fe4 [FLINK-40148][common] Fix TableIdRouter producing wrong sink 
table for multi-digit source suffix (#4473)
73a547fe4 is described below

commit 73a547fe46449d11eb078f6a58b44483d40bf5fe
Author: Pei Yu <[email protected]>
AuthorDate: Tue Sep 22 10:39:44 2026 +0800

    [FLINK-40148][common] Fix TableIdRouter producing wrong sink table for 
multi-digit source suffix (#4473)
---
 docs/content.zh/docs/core-concept/route.md         | 56 ++++++++++++++
 docs/content/docs/core-concept/route.md            | 67 +++++++++++++++++
 .../flink/cdc/common/route/TableIdRouter.java      | 14 +++-
 .../flink/cdc/common/route/TableIdRouterTest.java  | 87 ++++++++++++++++++++++
 4 files changed, 222 insertions(+), 2 deletions(-)

diff --git a/docs/content.zh/docs/core-concept/route.md 
b/docs/content.zh/docs/core-concept/route.md
index 88428f853..1642bfed8 100644
--- a/docs/content.zh/docs/core-concept/route.md
+++ b/docs/content.zh/docs/core-concept/route.md
@@ -130,3 +130,59 @@ route:
 注意:基于正则捕获组的替换规则无法与 `replace-symbol` 选项搭配使用。
 
 {{< /hint >}}
+
+# 兼容性说明 — 前缀匹配导致的 Sink-Table 后缀泄漏
+
+对 `source-table` 规则,`TableIdRouter` 先判断正则能否**完整匹配**整条 source 表 id,只有
+匹配成功才会执行替换。因此,路由结果对"完整匹配"和"前缀匹配"的一个特定交互非常敏感:
+
+- 正则能完整匹配整条 source 表 id(说明该规则整体上会生效),**且**
+- 正则也能匹配同一 source 表 id 的**更短前缀**(说明替换只会作用在前缀上)。
+
+当上述两个条件同时成立时,Flink CDC 3.6.0 会针对更短的前缀做替换,把未被匹配的尾部拼到
+sink-table 名字之后。出现这种情况的典型场景是正则含有按"短到长"排列的多分支(alternation),
+例如 `table_1|table_10`、`([1-9]|1[0-6])`,或者不带括号的 `[1-9]|1[0-6]`;是否带捕获组、
+sink 模板里是否使用 `$N` 反向引用都与此无关。
+
+从 Flink CDC 3.7.0 起,替换会作用在整个 source 表 id 上,不再出现后缀泄漏。下表展示了同样
+的配置在 3.6.0 与 3.7.0 中分别会被路由到哪里,便于直接对比。
+
+| Source 表 id         | source-table                              | sink-table 
                         | 3.6.0 中的路由结果                            | 3.7.0 
中的路由结果                       |
+|--------------------|-------------------------------------------|-------------------------------------|--------------------------------------------|-----------------------------------|
+| `new_db_6.table_1` | `new_db_6.table_([1-9]|1[0-6])`           | 
`new_db_6.table_$1_suffix`          | `new_db_6.table_1_suffix`                 
 | `new_db_6.table_1_suffix`         |
+| `new_db_6.table_13`| `new_db_6.table_([1-9]|1[0-6])`           | 
`new_db_6.table_$1_suffix`          | `new_db_6.table_1_suffix3`                
 | `new_db_6.table_13_suffix`        |
+| `new_db_6.table_13`| `new_db_6.table_([1-9]|1[0-6])`           | 
`new_db_6.table_merged`             | `new_db_6.table_merged3`                  
 | `new_db_6.table_merged`           |
+| `new_db_6.table_14`| `new_db_6.table_([1-9]|1[0-6])`           | 
`new_db_6.table_merged`             | `new_db_6.table_merged4`                  
 | `new_db_6.table_merged`           |
+
+如何阅读这张表:
+
+- 第一行是一位数字 source(`table_1`)的示例:两个版本结果恰好一致,因为"短前缀匹配"恰好
+  已经覆盖了整个 id,没有剩余字符可以泄漏到 sink-table。
+- 第二行展示 `$N` 反向引用情形:3.6.0 中捕获值被截断为首位数字(`1`),剩余字符被拼到
+  `_suffix` 字面量之后;3.7.0 中捕获到完整的多位数字。
+- 第三、四行展示不带 `$N` 反向引用的情形:3.6.0 中每个被匹配的 source 都会拼上自己的尾部
+  (`merged3`、`merged4` 等),相当于一个 source 表对应一个 sink 表;3.7.0 中所有匹配的 source
+  会汇聚到规则中声明的同一个 sink-table。
+
+## 对现有任务的兼容性影响
+
+本次行为变化影响所有同时满足"完整匹配"与"更短前缀匹配"的 `source-table` 规则:即同一
+条 source 表 id 既能被该正则完整匹配,也能被它匹配为一个更短的前缀。实际中最常见的就是按
+"短到长"排列的多分支 alternation,与是否带捕获组、是否使用 `$N` 反向引用都无关。仅使用
+字面量表名或 `replace-symbol` 选项的规则不受影响。
+
+对于**确实**会受影响的任务,从 3.6.0 升级到 3.7.0 后可能观察到以下行为变化:
+
+- **原本被(错误)路由的 source 表,会落到不同的 sink-table 上。**
+  - *使用 `$N` 反向引用时:* 捕获值由原来的截断子串变为完整的子串(例如从
+    `..._table_1_suffix3` 变为 `..._table_13_suffix`)。
+  - *不使用 `$N` 反向引用时:* 原本每个被匹配的 source 都会被拼上自己的尾部;修正后所有
+    匹配的 source 会汇聚到规则中声明的同一个 sink-table(例如从 `table_merged3` /
+    `table_merged4` / ... 全部变为 `table_merged`)。
+
+  如果已经在下游创建过旧(错误)名字的表,它们在升级后将不再是 pipeline 的目标。请决定是
+  重命名受影响的下游表,还是把已有数据迁移到新的(修正后的)sink 表中。
+
+总结一下:升级之前,请审查所有 route 规则,定位那些对同一 source 表 id 既能"完整匹配"也能
+匹配"更短前缀"的 source-table 正则,确认其匹配集合正是你期望的;如果有任务曾依赖 3.6.0 的
+后缀泄漏行为,请决定是重命名受影响的下游表,还是把已有数据迁移到新的(修正后的)sink 表中。
diff --git a/docs/content/docs/core-concept/route.md 
b/docs/content/docs/core-concept/route.md
index 2c1de4435..5b6517282 100644
--- a/docs/content/docs/core-concept/route.md
+++ b/docs/content/docs/core-concept/route.md
@@ -131,3 +131,70 @@ As a result, such table will be routed to downstream table 
`sink_db_foo.sink_tab
 Standard RegExp capturing could not be used with `replace-symbol` options.
 
 {{< /hint >}}
+
+# Compatibility Notes — Sink-Table Suffix Leak from Prefix Matches
+
+For a `source-table` rule, `TableIdRouter` first checks whether the regex 
matches the *entire*
+source table id, and only then runs the substitution. The exact routing 
behavior is therefore
+sensitive to a specific interaction between full-match and prefix-match:
+
+- The regex matches the full source table id (so the rule applies at all), 
**and**
+- The regex can also match a *shorter prefix* of the same source table id (so 
the substitution
+  would consume only part of the id).
+
+When both hold, the substitution in Flink CDC 3.6.0 was performed against the 
shorter prefix
+match, leaving the unmatched tail to be appended to the sink-table name. This 
commonly occurs
+when the regex has multiple alternatives ordered from shorter to longer — for 
example
+`table_1|table_10`, `([1-9]|1[0-6])`, or `[1-9]|1[0-6]` without parentheses — 
regardless of
+whether the rule uses capturing groups or `$N` back-references.
+
+Starting with Flink CDC 3.7.0, the substitution consumes the entire source 
table id, eliminating
+the suffix leak. The table below shows side-by-side what the same 
configuration routes to in
+3.6.0 versus 3.7.0.
+
+| Source table id       | source-table                              | 
sink-table                          | Routed to in 3.6.0                        
  | Routed to in 3.7.0                  |
+|-----------------------|-------------------------------------------|-------------------------------------|---------------------------------------------|-------------------------------------|
+| `new_db_6.table_1`    | `new_db_6.table_([1-9]|1[0-6])`           | 
`new_db_6.table_$1_suffix`          | `new_db_6.table_1_suffix`                 
  | `new_db_6.table_1_suffix`           |
+| `new_db_6.table_13`   | `new_db_6.table_([1-9]|1[0-6])`           | 
`new_db_6.table_$1_suffix`          | `new_db_6.table_1_suffix3`                
  | `new_db_6.table_13_suffix`          |
+| `new_db_6.table_13`   | `new_db_6.table_([1-9]|1[0-6])`           | 
`new_db_6.table_merged`             | `new_db_6.table_merged3`                  
  | `new_db_6.table_merged`             |
+| `new_db_6.table_14`   | `new_db_6.table_([1-9]|1[0-6])`           | 
`new_db_6.table_merged`             | `new_db_6.table_merged4`                  
  | `new_db_6.table_merged`             |
+
+Reading the table:
+
+- The first row shows a single-digit source (`table_1`) where the two versions 
happen to agree,
+  because the shorter-prefix match happens to consume the entire id, leaving 
nothing to leak.
+- The second row shows the `$N`-back-reference case: in 3.6.0 the captured 
value is truncated
+  to the first matched digit (`1`) and the trailing characters are 
concatenated after the
+  `_suffix` literal; in 3.7.0 the full multi-digit value is captured.
+- The third and fourth rows show the no-back-reference case: in 3.6.0 each 
matched source is
+  suffixed by its own unmatched tail (`merged3`, `merged4`, ...), effectively 
producing one
+  sink-table per source table; in 3.7.0 all matched sources collapse onto the 
single declared
+  sink-table.
+
+## Compatibility Impact on Existing Pipelines
+
+The change affects any `source-table` rule that admits a shorter prefix match 
alongside a full
+match on the same source table id. In practice this is most often a regex with 
alternation
+branches ordered from shorter to longer, with or without capturing groups and 
with or without
+`$N` back-references in the sink-table template. Rules that use only literal 
table names or the
+`replace-symbol` option are unaffected.
+
+For pipelines that *are* affected, upgrading from 3.6.0 to 3.7.0 may produce 
this kind of
+behavior change:
+
+- **Previously (mis-)routed sources land on a different sink-table id.**
+  - *With a `$N` back-reference:* the captured value becomes the full 
substring instead of the
+    truncated prefix (for example `..._table_13_suffix` instead of the old 
`..._table_1_suffix3`).
+  - *Without a `$N` back-reference:* every previously-matched source is now 
routed to the single
+    declared sink-table (for example `new_db_6.table_merged` instead of
+    `new_db_6.table_merged3`).
+
+  Either way, downstream tables that were created under the old (incorrect) 
name are no longer
+  the target of the pipeline. Reviewers should decide whether to rename the 
affected downstream
+  tables or to migrate the existing data into the new (correct) sink tables.
+
+In short, before upgrading, audit your route rules for any `source-table` 
regex whose shorter
+prefix matches form a strict subset of its full matches on the same ids, 
confirm that the
+rule's matching set is exactly what you intend, and — if any pipeline 
previously depended on
+the 3.6.0 suffix leak — decide whether to rename the affected downstream 
tables or to migrate
+the data into the new (correct) sink tables.
diff --git 
a/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/route/TableIdRouter.java
 
b/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/route/TableIdRouter.java
index 64d193c32..dc499b952 100755
--- 
a/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/route/TableIdRouter.java
+++ 
b/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/route/TableIdRouter.java
@@ -175,9 +175,19 @@ public class TableIdRouter {
         if (route.f2 != null) {
             return TableId.parse(route.f1.replace(route.f2, 
originalTable.getTableName()));
         } else {
+            // Use matches() so that the entire source table id is consumed. 
After matches() the
+            // matcher holds the full match, and we feed it through 
appendReplacement /
+            // appendTail to perform the `$N` substitution only on the matched 
region. We cannot
+            // use Matcher.replaceAll here because it internally calls reset() 
+ find(), which
+            // would only match a prefix of the source table id (when the 
regex alternation
+            // order matters, e.g. `([1-9]|1[0-6])` matching `1` in 
`db_6.table_13`), leaving `3` as
+            // a leftover that gets appended to the configured sink-table name 
(e.g. `...merged3`).
             Matcher matcher = route.f0.matcher(originalTable.toString());
-            if (matcher.find()) {
-                return TableId.parse(matcher.replaceAll(route.f1));
+            if (matcher.matches()) {
+                StringBuilder sb = new StringBuilder();
+                matcher.appendReplacement(sb, route.f1);
+                matcher.appendTail(sb);
+                return TableId.parse(sb.toString());
             }
         }
         return TableId.parse(route.f1);
diff --git 
a/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/common/route/TableIdRouterTest.java
 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/common/route/TableIdRouterTest.java
index c995e4289..4c8798a16 100644
--- 
a/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/common/route/TableIdRouterTest.java
+++ 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/common/route/TableIdRouterTest.java
@@ -357,6 +357,93 @@ public class TableIdRouterTest extends SchemaTestBase {
                                         
.containsExactly("ods.hi_bonus_record"));
     }
 
+    @Test
+    void testRouteWithoutBackReferenceWithMultiDigitSuffix() {
+        // Regression test: when the source-table regex matches multi-digit 
suffixes (e.g. 10-16)
+        // but the sink-table does NOT contain a `$1` back-reference, every 
matched source table
+        // should still be routed to the exact sink-table declared in the 
routing rule. Previously
+        // TableIdRouter.resolveReplacement used Matcher.find(), which only 
matched a prefix of
+        // the source table ID, leaving the unmatched tail characters to be 
appended to the
+        // sink-table after replaceAll. As a result, table `db_6.table_13` was 
wrongly routed to
+        // `new_db_6.table_merged3` (and similarly `db_6.table_14` to 
`new_db_6.table_merged4`).
+        List<String> sourceTables =
+                List.of(
+                        "db_6.table_1",
+                        "db_6.table_2",
+                        "db_6.table_9",
+                        "db_6.table_10",
+                        "db_6.table_11",
+                        "db_6.table_13",
+                        "db_6.table_14",
+                        "db_6.table_15",
+                        "db_6.table_16");
+        assertThat(
+                        testStdRegExpRoute(
+                                "db_6.table_([1-9]|1[0-6])", 
"new_db_6.table_merged", sourceTables))
+                .containsExactly(
+                        "[new_db_6.table_merged]",
+                        "[new_db_6.table_merged]",
+                        "[new_db_6.table_merged]",
+                        "[new_db_6.table_merged]",
+                        "[new_db_6.table_merged]",
+                        "[new_db_6.table_merged]",
+                        "[new_db_6.table_merged]",
+                        "[new_db_6.table_merged]",
+                        "[new_db_6.table_merged]");
+    }
+
+    @Test
+    void testRouteWithBackReferenceAndMultiDigitCapture() {
+        // Companion test for the same fix: when the sink-table uses a `$1` 
back-reference, the
+        // captured group must be the FULL multi-digit value (e.g. `13`), not 
just a single digit.
+        // Previously the alternation order in `([1-9]|1[0-6])` together with 
find() caused
+        // `[1-9]` to win against `1[0-6]` for input `13`, capturing only `1` 
and producing a
+        // sink-table name `..._1` followed by leftover `3`.
+        List<String> sourceTables =
+                List.of("db_6.table_1", "db_6.table_10", "db_6.table_13", 
"db_6.table_16");
+        assertThat(
+                        testStdRegExpRoute(
+                                "db_6.table_([1-9]|1[0-6])", 
"new_db_6.table_$1", sourceTables))
+                .containsExactly(
+                        "[new_db_6.table_1]",
+                        "[new_db_6.table_10]",
+                        "[new_db_6.table_13]",
+                        "[new_db_6.table_16]");
+    }
+
+    @Test
+    void testRouteWithBackReferenceSuffixAndMultiDigitCapture() {
+        // Genuine multi-character capture-group regression test: the 
sink-table template
+        // `new_db_6.table_$1_suffix` puts a literal `_suffix` AFTER the `$1` 
back-reference. With
+        // the pre-fix `Matcher.find()` + `Matcher.replaceAll()` 
implementation, the regex
+        // `new_db_6\.table_([1-9]|1[0-6])` matched the prefix 
`new_db_6.table_1` of the source
+        // table id `new_db_6.table_13`, leaving the unmatched tail `3` to be 
appended to the
+        // sink-table, producing the wrong sink-table id 
`new_db_6.table_1_suffix3` (note the `3`
+        // at the very end, after `_suffix`). The corrected implementation 
uses `matches()` and
+        // therefore consumes the entire source table id, yielding the expected
+        // `new_db_6.table_13_suffix`.
+        List<String> sourceTables =
+                List.of(
+                        "new_db_6.table_1",
+                        "new_db_6.table_2",
+                        "new_db_6.table_9",
+                        "new_db_6.table_10",
+                        "new_db_6.table_13",
+                        "new_db_6.table_16");
+        assertThat(
+                        testStdRegExpRoute(
+                                "new_db_6.table_([1-9]|1[0-6])",
+                                "new_db_6.table_$1_suffix",
+                                sourceTables))
+                .containsExactly(
+                        "[new_db_6.table_1_suffix]",
+                        "[new_db_6.table_2_suffix]",
+                        "[new_db_6.table_9_suffix]",
+                        "[new_db_6.table_10_suffix]",
+                        "[new_db_6.table_13_suffix]",
+                        "[new_db_6.table_16_suffix]");
+    }
+
     @Test
     void testRegExpComplexRouting() {
         // Capture the entire database.

Reply via email to