This is an automated email from the ASF dual-hosted git repository.
davidzollo pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git
The following commit(s) were added to refs/heads/dev by this push:
new c5ea35b929 [Docs] Align multi-table writer and replica-selection docs
with MultiTableSinkWriter (#11925)
c5ea35b929 is described below
commit c5ea35b92942ef8117abb497d5961fcacc196540
Author: Sepuri Sai Krishna <[email protected]>
AuthorDate: Sun Aug 23 19:00:46 2026 +0530
[Docs] Align multi-table writer and replica-selection docs with
MultiTableSinkWriter (#11925)
---
docs/en/architecture/features/multi-table.md | 208 ++++++++++++++++-----------
docs/zh/architecture/features/multi-table.md | 43 ++++--
2 files changed, 156 insertions(+), 95 deletions(-)
diff --git a/docs/en/architecture/features/multi-table.md
b/docs/en/architecture/features/multi-table.md
index 43d115a832..339b54b933 100644
--- a/docs/en/architecture/features/multi-table.md
+++ b/docs/en/architecture/features/multi-table.md
@@ -242,85 +242,88 @@ public class MultiTableSink<IN, StateT, CommitInfoT,
AggregatedCommitInfoT>
### 4.2 Writer: Multi-Table Writing with Replicas
-```java
-public class MultiTableSinkWriter<IN, CommitInfoT, StateT>
- implements SinkWriter<IN, CommitInfoT, StateT> {
-
- // Writers per table (multiple replicas per table)
- private final Map<SinkIdentifier, SinkWriter<IN, CommitInfoT, StateT>>
writers;
-
- // Replica count per table
- private final int replicaNum;
-
- // Context
- private final int writerIndex; // This writer's global index
-
- @Override
- public void write(IN element) throws IOException {
- SeaTunnelRow row = (SeaTunnelRow) element;
-
- // 1. Determine target table
- TablePath tablePath = row.getTablePath();
-
- // 2. Select replica for this table (load balancing)
- int replicaIndex = selectReplica(tablePath, row);
-
- // 3. Get writer for (table, replica)
- SinkIdentifier identifier = new SinkIdentifier(
- new TableIdentifier(tablePath),
- replicaIndex
- );
+`MultiTableSinkWriter` does not write rows inline. Each row is routed to one of
+`blockingQueues`, and a `MultiTableWriterRunnable` worker drains that queue
and writes
+through the sub-writers assigned to it. The queue index *is* the replica index.
- SinkWriter<IN, CommitInfoT, StateT> writer = writers.get(identifier);
+The listing below keeps the real class, field, and method names so it can be
read
+side by side with
+`seatunnel-api/src/main/java/org/apache/seatunnel/api/sink/multitablesink/MultiTableSinkWriter.java`.
+It is **simplified**: schema-change short-circuiting, quarantine checks,
retry, and
+exception wrapping are elided. It is not a copy of the source.
- // 4. Write to selected writer
- writer.write(element);
- }
+```java
+public class MultiTableSinkWriter
+ implements SinkWriter<SeaTunnelRow, MultiTableCommitInfo,
MultiTableState>,
+ SupportSchemaEvolutionSinkWriter {
- private int selectReplica(TablePath tablePath, SeaTunnelRow row) {
- // If primary key is available, route stably by primary key hash.
- Optional<Object> primaryKey = extractPrimaryKeyIfPresent(row);
- if (primaryKey.isPresent()) {
- return Math.abs(primaryKey.get().hashCode()) % replicaNum;
- }
+ // Sub-writers, keyed by (table identifier, replica index)
+ private final Map<SinkIdentifier, SinkWriter<SeaTunnelRow, ?, ?>>
sinkWriters;
- // Otherwise, distribute across replicas (no stable routing guarantee).
- return (int) (System.nanoTime() % replicaNum);
- }
+ // Primary-key field index per table id. An empty Optional means "table
has no
+ // primary key"; a missing key means "no sub-writer is registered for this
table".
+ private final ConcurrentMap<String, Optional<Integer>> sinkPrimaryKeys;
- @Override
- public Optional<CommitInfoT> prepareCommit(long checkpointId) throws
IOException {
- // Collect commit info from all writers
- List<CommitInfoT> allCommitInfos = new ArrayList<>();
+ // One queue per writer thread. The queue index is the replica index.
+ private final List<BlockingQueue<MultiTableWriterRunnable.QueueElement>>
blockingQueues;
- for (SinkWriter<IN, CommitInfoT, StateT> writer : writers.values()) {
- Optional<CommitInfoT> commitInfo =
writer.prepareCommit(checkpointId);
- commitInfo.ifPresent(allCommitInfos::add);
- }
+ // Sub-writers grouped by queue index; each runnable owns exactly one group
+ private final List<ConcurrentMap<SinkIdentifier, SinkWriter<SeaTunnelRow,
?, ?>>>
+ sinkWritersWithIndex;
- // Wrap in multi-table commit info
- return Optional.of((CommitInfoT) new
MultiTableCommitInfo(allCommitInfos));
- }
+ private final List<MultiTableWriterRunnable> runnable;
+ private final Random random = new Random();
+ private final MultiTableFailurePolicy failurePolicy;
@Override
- public List<StateT> snapshotState(long checkpointId) throws IOException {
- // Snapshot all writers
- List<StateT> allStates = new ArrayList<>();
-
- for (Map.Entry<SinkIdentifier, SinkWriter> entry : writers.entrySet())
{
- List<StateT> states = entry.getValue().snapshotState(checkpointId);
-
- // Tag states with sink identifier for recovery
- for (StateT state : states) {
- allStates.add(wrapWithIdentifier(entry.getKey(), state));
+ public void write(SeaTunnelRow element) throws IOException {
+ ensureQueueWorkersSubmitted();
+ subSinkErrorCheck();
+
+ // 1. Rows carry the table id, not a TablePath
+ String tableId = element.getTableId();
+ Optional<Integer> primaryKey = tableId == null ? null :
sinkPrimaryKeys.get(tableId);
+
+ if ((primaryKey == null && sinkPrimaryKeys.size() == 1)
+ || (primaryKey != null && !primaryKey.isPresent())) {
+ // 2a. No primary key to route on: spread rows across queues.
+ // No per-key ordering guarantee.
+ int index = random.nextInt(blockingQueues.size());
+ offerRowElement(index, element);
+
+ } else if (primaryKey == null) {
+ // 2b. No sub-writer registered for this table. Quarantine it and
keep the
+ // other tables running, or fail the job, per the failure
policy.
+ if (failurePolicy.continueOtherTables()) {
+ handleTableFailure(tableId,
MultiTableFailurePhase.RUNTIME_WRITE, ...);
+ return;
+ }
+ throw new RuntimeException("multi table sink can not write table:
" + tableId);
+
+ } else {
+ // 2c. Primary key present: route by its hash so equal keys always
reach the
+ // same queue, which is what preserves per-key ordering.
+ Object object = element.getField(primaryKey.get());
+ int index = 0;
+ if (object != null) {
+ // Known issue: Math.abs(Integer.MIN_VALUE) returns
Integer.MIN_VALUE
+ // unchanged (still negative), so a key hashing to it yields a
negative
+ // queue index. See section 5.3.
+ index = Math.abs(object.hashCode()) % blockingQueues.size();
}
+ offerRowElement(index, element);
}
-
- return allStates;
}
}
```
+Because rows are queued rather than written inline, the checkpoint methods
must first
+drain the queues. Both `prepareCommit(long)` and `snapshotState(long)` begin
with
+`checkQueueRemain()`, then call the corresponding method on every sub-writer
while
+holding that sub-writer's `MultiTableWriterRunnable` lock. Their real bodies
are
+dominated by parallel task submission, per-table retry, and failure-policy
handling,
+and are not reproduced here.
+
### 4.3 Committer: Multi-Table Commit Coordination
```java
@@ -393,18 +396,37 @@ sink {
### 5.3 Replica Selection Strategies
-**Hash-Based (when primary key is available)**:
+Both strategies select a queue index in `[0, blockingQueues.size())`. See
+`MultiTableSinkWriter.write(SeaTunnelRow)` for the full branch structure.
+
+**Hash-based (primary key available)** — the same key always reaches the same
queue,
+which is what preserves ordering for that key:
+
```java
-// Ensures same primary key always goes to same replica (order preservation)
-int replica = Math.abs(primaryKey.hashCode()) % replicaNum;
+int index = Math.abs(object.hashCode()) % blockingQueues.size();
```
-**Random (when primary key is not available)**:
+:::caution Known issue
+
+`Math.abs(Integer.MIN_VALUE)` returns `Integer.MIN_VALUE`, which is still
negative, so a
+key whose hash is exactly `Integer.MIN_VALUE` produces a negative index
whenever the queue
+count is not a power of two, and the subsequent `blockingQueues.get(index)`
throws
+`IndexOutOfBoundsException`. This page documents the behaviour currently on
`dev`; the
+defect is tracked in
[#11720](https://github.com/apache/seatunnel/issues/11720), and this
+section should be updated when a fix lands.
+
+:::
+
+**Random (no primary key available)** — spreads load, with no stable routing
guarantee:
+
```java
-// Distributes load across replicas (no stable routing guarantee)
-int replica = (int) (System.nanoTime() % replicaNum);
+int index = random.nextInt(blockingQueues.size());
```
+`random.nextInt(bound)` is used rather than a time-derived expression such as
+`System.nanoTime() % n`. `System.nanoTime()` is documented as possibly
negative, so that
+expression can produce a negative index for exactly the same reason `Math.abs`
can above.
+
## 6. Schema Management in Multi-Table
### 6.1 Independent Schemas
@@ -424,21 +446,39 @@ public class MultiTableSink {
### 6.2 Schema Evolution Routing
+A schema change is not applied by looping over sub-writers directly. It is
enqueued as a
+**barrier** onto every queue, so each worker applies it at the same position
in its own
+row stream and no row crosses the change out of order. Simplified, with the
failure
+collection and post-enqueue error recheck elided:
+
```java
-public class MultiTableSinkWriter {
- public void handleSchemaChange(SchemaChangeEvent event) {
- // Route schema change to correct table writer
- TablePath tablePath = event.getTableId().toTablePath();
-
- // Apply to all replicas of this table
- for (int i = 0; i < replicaNum; i++) {
- SinkIdentifier identifier = new SinkIdentifier(
- new TableIdentifier(tablePath),
- i
- );
-
- SinkWriter writer = writers.get(identifier);
- writer.applySchemaChange(event);
+public class MultiTableSinkWriter implements SupportSchemaEvolutionSinkWriter {
+
+ @Override
+ public void applySchemaChange(SchemaChangeEvent event) throws IOException {
+ subSinkErrorCheck();
+
+ // Events for tables this writer does not serve return immediately,
+ // without waking the queue workers.
+ if (!hasSourceMatchedWriter(event)) {
+ return;
+ }
+
+ ensureQueueWorkersSubmitted();
+ subSinkErrorCheck();
+ enqueueSchemaChangeBarrier(event);
+ }
+
+ private void enqueueSchemaChangeBarrier(SchemaChangeEvent event) throws
IOException {
+ // One barrier shared by all workers; it releases once every worker
has arrived.
+ SchemaChangeBarrier barrier =
+ new SchemaChangeBarrier(
+ event,
+ runnable.size(),
+ e -> dispatchSchemaChangeToTargets(e,
schemaChangeFailures));
+
+ for (BlockingQueue<MultiTableWriterRunnable.QueueElement> queue :
blockingQueues) {
+ offerQueueElement(queue,
MultiTableWriterRunnable.schemaChangeRequest(barrier));
}
}
}
diff --git a/docs/zh/architecture/features/multi-table.md
b/docs/zh/architecture/features/multi-table.md
index cbc3299161..71067d5a80 100644
--- a/docs/zh/architecture/features/multi-table.md
+++ b/docs/zh/architecture/features/multi-table.md
@@ -127,21 +127,24 @@ MultiTableSink 是一个“按表路由 + 可多副本并行写入”的 Sink:
### 4.2 写入器: 带副本的多表写入
写入器的关键流程:
-1. 从输入记录中解析 TablePath(tableId)
-2. 为该表选择一个 writer 副本(replicaIndex)
-3. 路由到 (TablePath, replicaIndex) 对应的底层 writer 执行写入
+1. 从输入记录中读取 tableId(`SeaTunnelRow.getTableId()`)
+2. 依据该表登记的主键字段信息选择一个队列下标(即副本下标 replicaIndex)
+3. 将记录投递到对应的 `blockingQueues`,由 `MultiTableWriterRunnable` 工作线程消费并调用底层 writer
写入
+
+注意:记录并非在 `write()` 中同步写入底层 writer,而是先入队、再由工作线程异步写出。队列下标即副本下标。
副本选择需要兼顾两类诉求:
- **顺序性/一致落点**: 对同一主键(或唯一键)相关的记录尽量路由到同一副本,降低乱序与写入冲突风险
- **吞吐量**: 在不破坏顺序性要求的前提下,尽量分散写入压力
-在当前 MultiTableSinkWriter 的实现中,副本选择主要依据“主键信息是否可用”:
-- 有主键:对主键字段做哈希,稳定映射到某个副本
-- 无主键:使用随机策略在副本间分配
+在当前 MultiTableSinkWriter 的实现中,副本选择分为三个分支:
+- **有主键**:对主键字段值做哈希,稳定映射到某个队列
+- **无主键**:使用随机策略(`Random.nextInt`)在队列间分配
+- **该表未登记 writer**:按 `MultiTableFailurePolicy` 处理——或隔离该表并继续写入其他表,或直接抛出异常终止作业
这意味着“是否按 rowKind(INSERT/UPDATE/DELETE)切换策略”不是该实现的默认行为;如果需要按 rowKind 细分策略,应以
connector/实现代码为准。
-在 checkpoint 边界:
+在 checkpoint 边界(两者都先调用 `checkQueueRemain()` 排空队列):
- prepareCommit: 汇总所有表/所有副本的 CommitInfo,并打包为多表级提交信息
- snapshotState: 快照所有 writer 状态;恢复时必须能通过 SinkIdentifier 将状态路由回正确的(表,副本)
@@ -185,17 +188,31 @@ sink {
### 5.3 副本选择策略
+两种策略都在 `[0, blockingQueues.size())` 范围内选择队列下标,完整分支结构参见
+`MultiTableSinkWriter.write(SeaTunnelRow)`。
+
**基于主键哈希(稳定路由)**:
要点:
- 以主键(或业务唯一键)做哈希,将同一键稳定映射到同一副本
-- 典型映射: $replica = hash(pk) \bmod replicaNum$
+- 当前实现: $replica = \mathrm{Math.abs}(hash(pk)) \bmod replicaNum$
+
+:::caution 已知问题
+
+`Math.abs(Integer.MIN_VALUE)` 仍返回 `Integer.MIN_VALUE`(负数),因此当主键哈希恰好为
+`Integer.MIN_VALUE` 且副本数不是 2 的幂时会得到负的下标,随后的
+`blockingQueues.get(index)` 会抛出 `IndexOutOfBoundsException`。本页描述的是 `dev`
+分支当前的实际行为;该缺陷记录在
+[#11720](https://github.com/apache/seatunnel/issues/11720),修复合入后需同步更新本节。
+
+:::
**随机(无主键兜底)**:
要点:
- 当记录缺少主键字段信息时,无法提供稳定落点
-- 使用随机分配在副本间扩散压力,但不保证同一键的顺序性
+- 使用 `Random.nextInt(blockingQueues.size())` 在副本间扩散压力,但不保证同一键的顺序性
+- 不使用 `System.nanoTime() % replicaNum` 之类的写法:`nanoTime()` 可能为负,同样会产生负下标
## 6. 多表中的模式管理
@@ -210,8 +227,12 @@ sink {
模式演化需要被路由到“正确的表”,并应用到该表的所有 writer 副本:
1. 从 SchemaChangeEvent 中解析出 TablePath
-2. 选择该表对应的 schema/元数据更新逻辑
-3. 将变更广播到该表的所有副本 writer,保证后续写入使用一致的 schema
+2. 若当前 writer 不服务该表(`hasSourceMatchedWriter` 返回 false),直接返回,不唤醒队列工作线程
+3. 将变更以**屏障(`SchemaChangeBarrier`)**的形式投递到所有 `blockingQueues`
+4. 每个工作线程在各自行流的同一位置应用该变更,保证不会有记录跨越 schema 变更乱序写出
+
+注意:模式变更并非直接遍历各副本 writer 同步调用,而是与普通行记录共用同一条队列路径,
+以此保证变更与行记录之间的相对顺序。
## 7. 数据流示例