This is an automated email from the ASF dual-hosted git repository.
swuferhong pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss.git
The following commit(s) were added to refs/heads/main by this push:
new 1dfa13fa7 [server] Add table-level config for standby replica with
ALTER TABLE support (#3257)
1dfa13fa7 is described below
commit 1dfa13fa78f75413e605caf713bbb4379a7ffccd
Author: yunhong <[email protected]>
AuthorDate: Wed Jun 3 18:14:04 2026 +0800
[server] Add table-level config for standby replica with ALTER TABLE
support (#3257)
---
.../fluss/client/admin/FlussAdminITCase.java | 3 +
.../fluss/client/table/FlussTableITCase.java | 1 +
.../org/apache/fluss/config/ConfigOptions.java | 11 ++
.../org/apache/fluss/config/FlussConfigUtils.java | 3 +-
.../java/org/apache/fluss/config/TableConfig.java | 8 +
.../org/apache/fluss/metadata/TableDescriptor.java | 12 ++
.../fluss/flink/catalog/FlinkCatalogITCase.java | 2 +
.../fluss/flink/utils/CatalogTableTestUtils.java | 3 +
.../coordinator/CoordinatorEventProcessor.java | 26 ++-
.../server/coordinator/CoordinatorService.java | 8 +-
.../statemachine/ReplicaLeaderElection.java | 22 +--
.../statemachine/TableBucketStateMachine.java | 28 +--
.../server/utils/TableDescriptorValidation.java | 9 +
.../coordinator/CoordinatorEventProcessorTest.java | 187 +++++++++++++++++++++
.../server/coordinator/TableManagerITCase.java | 3 +-
website/docs/engine-flink/options.md | 5 +-
16 files changed, 303 insertions(+), 28 deletions(-)
diff --git
a/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java
b/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java
index 162f3c158..65cca8195 100644
---
a/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java
+++
b/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java
@@ -295,6 +295,7 @@ class FlussAdminITCase extends ClientToServerITCaseBase {
options.put(
ConfigOptions.TABLE_KV_FORMAT_VERSION.key(),
String.valueOf(CURRENT_KV_FORMAT_VERSION));
+ options.put(ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED.key(),
"true");
assertThat(tableInfo.toTableDescriptor())
.isEqualTo(tableDescriptor.withProperties(options));
assertThat(schemaInfo2).isEqualTo(schemaInfo);
@@ -324,6 +325,7 @@ class FlussAdminITCase extends ClientToServerITCaseBase {
options.put(
ConfigOptions.TABLE_KV_FORMAT_VERSION.key(),
String.valueOf(CURRENT_KV_FORMAT_VERSION));
+ options.put(ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED.key(),
"true");
assertThat(tableInfo.toTableDescriptor()).isEqualTo(expected.withProperties(options));
assertThat(schemaInfo2).isEqualTo(schemaInfo);
// assert created time
@@ -908,6 +910,7 @@ class FlussAdminITCase extends ClientToServerITCaseBase {
options.put(
ConfigOptions.TABLE_KV_FORMAT_VERSION.key(),
String.valueOf(CURRENT_KV_FORMAT_VERSION));
+ options.put(ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED.key(),
"true");
assertThat(tableInfo.toTableDescriptor()).isEqualTo(expected.withProperties(options));
}
}
diff --git
a/fluss-client/src/test/java/org/apache/fluss/client/table/FlussTableITCase.java
b/fluss-client/src/test/java/org/apache/fluss/client/table/FlussTableITCase.java
index 1d3865a08..03bcb8d96 100644
---
a/fluss-client/src/test/java/org/apache/fluss/client/table/FlussTableITCase.java
+++
b/fluss-client/src/test/java/org/apache/fluss/client/table/FlussTableITCase.java
@@ -109,6 +109,7 @@ class FlussTableITCase extends ClientToServerITCaseBase {
.withDataLakeFormat(DataLakeFormat.PAIMON);
Map<String, String> options = new HashMap<>(expected.getProperties());
options.put(ConfigOptions.TABLE_KV_FORMAT_VERSION.key(), "2");
+ options.put(ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED.key(),
"true");
expected = expected.withProperties(options);
assertThat(tableInfo.toTableDescriptor()).isEqualTo(expected);
}
diff --git
a/fluss-common/src/main/java/org/apache/fluss/config/ConfigOptions.java
b/fluss-common/src/main/java/org/apache/fluss/config/ConfigOptions.java
index d0ede21a7..ec819f575 100644
--- a/fluss-common/src/main/java/org/apache/fluss/config/ConfigOptions.java
+++ b/fluss-common/src/main/java/org/apache/fluss/config/ConfigOptions.java
@@ -1503,6 +1503,17 @@ public class ConfigOptions {
+ "for optimization (encoded bytes can be
reused for bucket calculation). "
+ "Bucket key encoding always uses
datalake's encoder to align with datalake bucket calculation.");
+ public static final ConfigOption<Boolean> TABLE_KV_STANDBY_REPLICA_ENABLED
=
+ key("table.kv.standby-replica.enabled")
+ .booleanType()
+ .noDefaultValue()
+ .withDescription(
+ "Whether to enable standby replicas for primary
key tables. "
+ + "Standby replicas maintain recent KV
snapshots for fast leader promotion. "
+ + "Automatically set to true by the
coordinator during table creation for new PK tables. "
+ + "Tables created before this option was
introduced are treated as disabled. "
+ + "Can be dynamically enabled via ALTER
TABLE.");
+
public static final ConfigOption<Boolean> TABLE_AUTO_PARTITION_ENABLED =
key("table.auto-partition.enabled")
.booleanType()
diff --git
a/fluss-common/src/main/java/org/apache/fluss/config/FlussConfigUtils.java
b/fluss-common/src/main/java/org/apache/fluss/config/FlussConfigUtils.java
index 7daa48c2b..17cfcb407 100644
--- a/fluss-common/src/main/java/org/apache/fluss/config/FlussConfigUtils.java
+++ b/fluss-common/src/main/java/org/apache/fluss/config/FlussConfigUtils.java
@@ -50,7 +50,8 @@ public class FlussConfigUtils {
ConfigOptions.TABLE_DATALAKE_FRESHNESS.key(),
ConfigOptions.TABLE_TIERED_LOG_LOCAL_SEGMENTS.key(),
ConfigOptions.TABLE_AUTO_PARTITION_NUM_RETENTION.key(),
- ConfigOptions.TABLE_STATISTICS_COLUMNS.key());
+ ConfigOptions.TABLE_STATISTICS_COLUMNS.key(),
+ ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED.key());
}
public static boolean isTableStorageConfig(String key) {
diff --git
a/fluss-common/src/main/java/org/apache/fluss/config/TableConfig.java
b/fluss-common/src/main/java/org/apache/fluss/config/TableConfig.java
index 13cb49e9e..fbf8c7726 100644
--- a/fluss-common/src/main/java/org/apache/fluss/config/TableConfig.java
+++ b/fluss-common/src/main/java/org/apache/fluss/config/TableConfig.java
@@ -77,6 +77,14 @@ public class TableConfig {
return config.getOptional(ConfigOptions.TABLE_KV_FORMAT_VERSION);
}
+ /**
+ * Whether standby replicas are enabled for this primary key table.
Returns false for legacy
+ * tables that were created before this option was introduced.
+ */
+ public boolean isStandbyReplicaEnabled() {
+ return
config.getOptional(ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED).orElse(false);
+ }
+
/** Gets the log TTL of the table. */
public long getLogTTLMs() {
return config.get(ConfigOptions.TABLE_LOG_TTL).toMillis();
diff --git
a/fluss-common/src/main/java/org/apache/fluss/metadata/TableDescriptor.java
b/fluss-common/src/main/java/org/apache/fluss/metadata/TableDescriptor.java
index 9f18dd401..fc19f6b7e 100644
--- a/fluss-common/src/main/java/org/apache/fluss/metadata/TableDescriptor.java
+++ b/fluss-common/src/main/java/org/apache/fluss/metadata/TableDescriptor.java
@@ -281,6 +281,18 @@ public final class TableDescriptor implements Serializable
{
return withProperties(newProperties);
}
+ /**
+ * Returns a new TableDescriptor instance that is a copy of this
TableDescriptor with a new
+ * standby replica enabled.
+ */
+ public TableDescriptor withStandbyReplicaEnabled(boolean
standbyReplicaEnabled) {
+ Map<String, String> newProperties = new HashMap<>(properties);
+ newProperties.put(
+ ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED.key(),
+ String.valueOf(standbyReplicaEnabled));
+ return withProperties(newProperties);
+ }
+
/**
* Returns a new TableDescriptor instance that is a copy of this
TableDescriptor with a new
* bucket count.
diff --git
a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/catalog/FlinkCatalogITCase.java
b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/catalog/FlinkCatalogITCase.java
index eebe58611..8991618fd 100644
---
a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/catalog/FlinkCatalogITCase.java
+++
b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/catalog/FlinkCatalogITCase.java
@@ -686,6 +686,7 @@ abstract class FlinkCatalogITCase {
expectedTableProperties.put("table.replication.factor", "1");
expectedTableProperties.put(
"table.kv.format-version",
String.valueOf(CURRENT_KV_FORMAT_VERSION));
+ expectedTableProperties.put("table.kv.standby-replica.enabled",
"true");
assertThat(tableInfo.getProperties().toMap()).isEqualTo(expectedTableProperties);
Map<String, String> expectedCustomProperties = new HashMap<>();
@@ -1076,6 +1077,7 @@ abstract class FlinkCatalogITCase {
actualOptions.remove(ConfigOptions.BOOTSTRAP_SERVERS.key());
actualOptions.remove(ConfigOptions.TABLE_REPLICATION_FACTOR.key());
actualOptions.remove(ConfigOptions.TABLE_KV_FORMAT_VERSION.key());
+
actualOptions.remove(ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED.key());
assertThat(actualOptions).isEqualTo(expectedOptions);
}
}
diff --git
a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/utils/CatalogTableTestUtils.java
b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/utils/CatalogTableTestUtils.java
index 2c255a40e..975b515eb 100644
---
a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/utils/CatalogTableTestUtils.java
+++
b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/utils/CatalogTableTestUtils.java
@@ -27,6 +27,7 @@ import java.util.Map;
import static org.apache.fluss.config.ConfigOptions.BOOTSTRAP_SERVERS;
import static org.apache.fluss.config.ConfigOptions.TABLE_DATALAKE_FORMAT;
import static org.apache.fluss.config.ConfigOptions.TABLE_KV_FORMAT_VERSION;
+import static
org.apache.fluss.config.ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED;
import static org.apache.fluss.config.ConfigOptions.TABLE_REPLICATION_FACTOR;
import static org.assertj.core.api.Assertions.assertThat;
@@ -142,6 +143,8 @@ public class CatalogTableTestUtils {
// Remove datalake format (auto-added when datalake is enabled in
Fluss cluster)
actualOptions.remove(TABLE_DATALAKE_FORMAT.key());
actualOptions.remove(TABLE_KV_FORMAT_VERSION.key());
+ // Remove standby replica (auto-added in Fluss cluster)
+ actualOptions.remove(TABLE_KV_STANDBY_REPLICA_ENABLED.key());
assertThat(actualOptions).isEqualTo(expectedOptions);
}
}
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java
index 96f39ef08..00ca807f0 100644
---
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java
+++
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java
@@ -814,7 +814,31 @@ public class CoordinatorEventProcessor implements
EventProcessor {
autoPartitionManager.updateAutoPartitionTables(newTableInfo);
}
- // more post-alter actions can be added here
+ // If standby replica config changed, trigger re-election for all
online buckets
+ // of this table to apply new standby replica assignment
+ boolean oldStandbyEnabled =
oldTableInfo.getTableConfig().isStandbyReplicaEnabled();
+ boolean newStandbyEnabled =
newTableInfo.getTableConfig().isStandbyReplicaEnabled();
+ if (oldStandbyEnabled != newStandbyEnabled) {
+ triggerReElectionForTable(newTableInfo.getTableId());
+ }
+ }
+
+ private void triggerReElectionForTable(long tableId) {
+ Set<TableBucket> onlineBuckets =
+ coordinatorContext.getAllBuckets().stream()
+ .filter(
+ tb ->
+ tb.getTableId() == tableId
+ &&
coordinatorContext.getBucketState(tb)
+ == OnlineBucket)
+ .collect(Collectors.toSet());
+ if (!onlineBuckets.isEmpty()) {
+ LOG.info(
+ "Triggering re-election for {} buckets of table {} due to
standby replica config change.",
+ onlineBuckets.size(),
+ tableId);
+ tableBucketStateMachine.handleStateChange(onlineBuckets,
OnlineBucket);
+ }
}
private void processCreatePartition(CreatePartitionEvent
createPartitionEvent) {
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java
index e96e36f45..5a37aa9a6 100644
---
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java
+++
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java
@@ -655,7 +655,6 @@ public final class CoordinatorService extends
RpcServiceBase implements Coordina
newProperties.put(
ConfigOptions.TABLE_KV_FORMAT_VERSION.key(),
String.valueOf(CURRENT_KV_FORMAT_VERSION));
- newDescriptor = newDescriptor.withProperties(newProperties);
} else {
if (formatVersion > CURRENT_KV_FORMAT_VERSION) {
throw new InvalidConfigException(
@@ -665,6 +664,13 @@ public final class CoordinatorService extends
RpcServiceBase implements Coordina
formatVersion, CURRENT_KV_FORMAT_VERSION));
}
}
+
+ // Enable standby replica for new PK tables if not explicitly
configured
+ if
(!newProperties.containsKey(ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED.key()))
{
+
newProperties.put(ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED.key(), "true");
+ }
+
+ newDescriptor = newDescriptor.withProperties(newProperties);
}
return newDescriptor;
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/statemachine/ReplicaLeaderElection.java
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/statemachine/ReplicaLeaderElection.java
index d63714423..d2e01eb8c 100644
---
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/statemachine/ReplicaLeaderElection.java
+++
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/statemachine/ReplicaLeaderElection.java
@@ -42,14 +42,14 @@ public abstract class ReplicaLeaderElection {
* @param assignments the assignments
* @param aliveReplicas the alive replicas
* @param leaderAndIsr the original leaderAndIsr
- * @param isPrimaryKeyTable whether this table bucket is primary key
table
+ * @param standbyReplicaEnabled whether standby replica is enabled for
this table bucket
* @return the election result
*/
public Optional<ElectionResult> leaderElection(
List<Integer> assignments,
List<Integer> aliveReplicas,
LeaderAndIsr leaderAndIsr,
- boolean isPrimaryKeyTable) {
+ boolean standbyReplicaEnabled) {
List<Integer> isr = leaderAndIsr.isr();
// First we will filter out the assignment list to only contain
the alive replicas and
// isr.
@@ -61,7 +61,7 @@ public abstract class ReplicaLeaderElection {
&& isr.contains(replica))
.collect(Collectors.toList());
return electLeader(
- availableReplicas, aliveReplicas, isr, leaderAndIsr,
isPrimaryKeyTable);
+ availableReplicas, aliveReplicas, isr, leaderAndIsr,
standbyReplicaEnabled);
}
}
@@ -74,7 +74,7 @@ public abstract class ReplicaLeaderElection {
* @param aliveReplicas the alive replicas
* @param leaderAndIsr the original leaderAndIsr
* @param shutdownTabletServers the shutdown tabletServers
- * @param isPrimaryKeyTable whether this table bucket is primary key
table
+ * @param standbyReplicaEnabled whether standby replica is enabled for
this table bucket
* @return the election result
*/
public Optional<ElectionResult> leaderElection(
@@ -82,7 +82,7 @@ public abstract class ReplicaLeaderElection {
List<Integer> aliveReplicas,
LeaderAndIsr leaderAndIsr,
Set<Integer> shutdownTabletServers,
- boolean isPrimaryKeyTable) {
+ boolean standbyReplicaEnabled) {
List<Integer> originIsr = leaderAndIsr.isr();
Set<Integer> isrSet = new HashSet<>(originIsr);
// Filter out available replicas: alive, in ISR, and not shutting
down.
@@ -108,7 +108,7 @@ public abstract class ReplicaLeaderElection {
new ArrayList<>(newAliveReplicaSet),
newIsr,
leaderAndIsr,
- isPrimaryKeyTable);
+ standbyReplicaEnabled);
}
}
@@ -121,7 +121,9 @@ public abstract class ReplicaLeaderElection {
}
public Optional<ElectionResult> leaderElection(
- List<Integer> liveReplicas, LeaderAndIsr leaderAndIsr, boolean
isPrimaryKeyTable) {
+ List<Integer> liveReplicas,
+ LeaderAndIsr leaderAndIsr,
+ boolean standbyReplicaEnabled) {
// For bucket reassignment, the first replica in newReplicas is
the target leader
// encoded by GoalOptimizerUtils. We must always honor this target
instead of using
// standby-promotion logic, otherwise the elected leader may
differ from the rebalance
@@ -141,7 +143,7 @@ public abstract class ReplicaLeaderElection {
// Always use the first available replica as leader to honor the
rebalance plan.
int newLeader = availableReplicas.get(0);
List<Integer> standbyReplicas =
- isPrimaryKeyTable
+ standbyReplicaEnabled
? findNewStandby(availableReplicas, newLeader)
: Collections.emptyList();
@@ -161,13 +163,13 @@ public abstract class ReplicaLeaderElection {
List<Integer> liveReplicas,
List<Integer> newIsr,
LeaderAndIsr leaderAndIsr,
- boolean isPrimaryKeyTable) {
+ boolean standbyReplicaEnabled) {
if (availableReplicas.isEmpty()) {
return Optional.empty();
}
// For log table, simply use the first available replica as leader.
- if (!isPrimaryKeyTable) {
+ if (!standbyReplicaEnabled) {
return Optional.of(
new ElectionResult(
liveReplicas,
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/statemachine/TableBucketStateMachine.java
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/statemachine/TableBucketStateMachine.java
index 6a556ddea..2677b85c3 100644
---
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/statemachine/TableBucketStateMachine.java
+++
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/statemachine/TableBucketStateMachine.java
@@ -20,6 +20,7 @@ package org.apache.fluss.server.coordinator.statemachine;
import org.apache.fluss.annotation.VisibleForTesting;
import org.apache.fluss.metadata.PhysicalTablePath;
import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TableInfo;
import org.apache.fluss.server.coordinator.CoordinatorContext;
import org.apache.fluss.server.coordinator.CoordinatorRequestBatch;
import
org.apache.fluss.server.coordinator.statemachine.ReplicaLeaderElection.ControlledShutdownLeaderElection;
@@ -438,14 +439,15 @@ public class TableBucketStateMachine {
}
// For the case that the table bucket has been initialized, we use all
the live assigned
// servers as inSyncReplica set.
+ TableInfo tableInfo =
coordinatorContext.getTableInfoById(tableBucket.getTableId());
+ boolean standbyEnabled =
+ tableInfo.hasPrimaryKey() &&
tableInfo.getTableConfig().isStandbyReplicaEnabled();
Optional<ElectionResult> resultOpt =
initReplicaLeaderElection(
assignedServers,
liveServers,
coordinatorContext.getCoordinatorEpoch(),
- coordinatorContext
- .getTableInfoById(tableBucket.getTableId())
- .hasPrimaryKey());
+ standbyEnabled);
if (!resultOpt.isPresent()) {
LOG.error(
"The leader election for table bucket {} is empty.",
@@ -625,12 +627,14 @@ public class TableBucketStateMachine {
}
Optional<ElectionResult> resultOpt = Optional.empty();
- boolean isPkTable =
-
coordinatorContext.getTableInfoById(tableBucket.getTableId()).hasPrimaryKey();
+ TableInfo tableInfo =
coordinatorContext.getTableInfoById(tableBucket.getTableId());
+ boolean standbyReplicaEnabled =
+ tableInfo.hasPrimaryKey() &&
tableInfo.getTableConfig().isStandbyReplicaEnabled();
if (electionStrategy instanceof DefaultLeaderElection) {
resultOpt =
((DefaultLeaderElection) electionStrategy)
- .leaderElection(assignment, liveReplicas,
leaderAndIsr, isPkTable);
+ .leaderElection(
+ assignment, liveReplicas, leaderAndIsr,
standbyReplicaEnabled);
} else if (electionStrategy instanceof
ControlledShutdownLeaderElection) {
Set<Integer> shuttingDownTabletServers =
coordinatorContext.shuttingDownTabletServers();
resultOpt =
@@ -640,11 +644,11 @@ public class TableBucketStateMachine {
liveReplicas,
leaderAndIsr,
shuttingDownTabletServers,
- isPkTable);
+ standbyReplicaEnabled);
} else if (electionStrategy instanceof ReassignmentLeaderElection) {
resultOpt =
((ReassignmentLeaderElection) electionStrategy)
- .leaderElection(liveReplicas, leaderAndIsr,
isPkTable);
+ .leaderElection(liveReplicas, leaderAndIsr,
standbyReplicaEnabled);
}
if (!resultOpt.isPresent()) {
@@ -685,7 +689,7 @@ public class TableBucketStateMachine {
* @param assignments the assignments
* @param aliveReplicas the alive replicas
* @param coordinatorEpoch the coordinator epoch
- * @param isPrimaryKeyTable whether this table bucket is primary key table
+ * @param standbyReplicaEnabled whether standby replica is enabled for
this table bucket
* @return the election result
*/
@VisibleForTesting
@@ -693,7 +697,7 @@ public class TableBucketStateMachine {
List<Integer> assignments,
List<Integer> aliveReplicas,
int coordinatorEpoch,
- boolean isPrimaryKeyTable) {
+ boolean standbyReplicaEnabled) {
// First we will filter out the assignment list to only contain the
alive replicas.
List<Integer> availableReplicas =
assignments.stream().filter(aliveReplicas::contains).collect(Collectors.toList());
@@ -706,10 +710,10 @@ public class TableBucketStateMachine {
// Then we will use the first replica in assignment as the leader
replica.
int leader = availableReplicas.get(0);
- // If this table is primaryKey table, we will use the second replica
in assignment as the
+ // If standby replica is enabled, we will use the second replica in
assignment as the
// standby if exists.
List<Integer> standbyReplicas = new ArrayList<>();
- if (isPrimaryKeyTable) {
+ if (standbyReplicaEnabled) {
if (availableReplicas.size() > 1) {
standbyReplicas.add(availableReplicas.get(1));
}
diff --git
a/fluss-server/src/main/java/org/apache/fluss/server/utils/TableDescriptorValidation.java
b/fluss-server/src/main/java/org/apache/fluss/server/utils/TableDescriptorValidation.java
index fcd5f1688..949afd8c6 100644
---
a/fluss-server/src/main/java/org/apache/fluss/server/utils/TableDescriptorValidation.java
+++
b/fluss-server/src/main/java/org/apache/fluss/server/utils/TableDescriptorValidation.java
@@ -170,6 +170,15 @@ public class TableDescriptorValidation {
.collect(Collectors.joining(", "))));
}
+ // Standby replica is only applicable to primary key tables
+ if
(tableKeysToChange.contains(ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED.key())
+ && !currentTable.hasPrimaryKey()) {
+ throw new InvalidAlterTableException(
+ String.format(
+ "'%s' can only be altered on primary key tables.",
+
ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED.key()));
+ }
+
if (!currentConfig.getDataLakeFormat().isPresent()) {
List<String> datalakeKeys =
tableKeysToChange.stream()
diff --git
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java
index e1226156e..d6813abb0 100644
---
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java
+++
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java
@@ -24,6 +24,7 @@ import org.apache.fluss.cluster.rebalance.RebalanceStatus;
import org.apache.fluss.config.ConfigOptions;
import org.apache.fluss.config.Configuration;
import org.apache.fluss.exception.FencedLeaderEpochException;
+import org.apache.fluss.exception.InvalidAlterTableException;
import org.apache.fluss.exception.InvalidCoordinatorException;
import org.apache.fluss.fs.FsPath;
import org.apache.fluss.metadata.DatabaseDescriptor;
@@ -144,6 +145,7 @@ class CoordinatorEventProcessorTest {
.primaryKey("a")
.build())
.distributedBy(3, "a")
+
.property(ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED.key(), "true")
.build()
.withReplicationFactor(REPLICATION_FACTOR);
@@ -1009,6 +1011,191 @@ class CoordinatorEventProcessorTest {
});
}
+ @Test
+ void testAlterStandbyReplicaEnabled() throws Exception {
+ // make sure all request to gateway should be successful
+ initCoordinatorChannel();
+
+ // create a PK table with standby replica enabled (TEST_TABLE has it
enabled)
+ TablePath t1 = TablePath.of(defaultDatabase,
"test_alter_standby_replica");
+ int nBuckets = 1;
+ int replicationFactor = 3;
+ TableAssignment tableAssignment =
+ generateAssignment(
+ nBuckets,
+ replicationFactor,
+ new TabletServerInfo[] {
+ new TabletServerInfo(0, "rack0"),
+ new TabletServerInfo(1, "rack1"),
+ new TabletServerInfo(2, "rack2")
+ });
+ long tableId =
+ metadataManager.createTable(t1, remoteDataDir, TEST_TABLE,
tableAssignment, false);
+ TableBucket tableBucket = new TableBucket(tableId, 0);
+
+ // wait for the bucket to become online with standby replica assigned
+ retryVerifyContext(
+ ctx -> {
+
assertThat(ctx.getBucketState(tableBucket)).isEqualTo(OnlineBucket);
+ LeaderAndIsr leaderAndIsr =
ctx.getBucketLeaderAndIsr(tableBucket).get();
+ // Standby should be assigned since the table is PK with
standby enabled
+ assertThat(leaderAndIsr.standbyReplicas()).hasSize(1);
+ });
+
+ // ALTER: disable standby replica
+ TablePropertyChanges.Builder disableBuilder =
TablePropertyChanges.builder();
+ disableBuilder.setTableProperty(
+ ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED.key(), "false");
+ metadataManager.alterTableProperties(
+ t1, Collections.emptyList(), disableBuilder.build(), false,
null);
+
+ // verify standby replicas are removed after re-election
+ retryVerifyContext(
+ ctx -> {
+ TableInfo tableInfoInCtx = ctx.getTableInfoById(tableId);
+
assertThat(tableInfoInCtx.getTableConfig().isStandbyReplicaEnabled()).isFalse();
+ LeaderAndIsr leaderAndIsr =
ctx.getBucketLeaderAndIsr(tableBucket).get();
+ assertThat(leaderAndIsr.standbyReplicas()).isEmpty();
+ });
+
+ // Also verify from ZooKeeper that standby replicas are cleared
+ LeaderAndIsr zkLeaderAndIsr =
zookeeperClient.getLeaderAndIsr(tableBucket).get();
+ assertThat(zkLeaderAndIsr.standbyReplicas()).isEmpty();
+ }
+
+ @Test
+ void testAlterEnableStandbyReplicaForExistingTable() throws Exception {
+ // Simulate a legacy PK table created without standby replica config.
+ // After ALTER to enable standby replica, re-election should be
triggered
+ // and standby replicas should be assigned.
+ initCoordinatorChannel();
+
+ // Create PK table WITHOUT standby replica enabled (simulating legacy
table)
+ TablePath t1 = TablePath.of(defaultDatabase,
"test_enable_standby_existing");
+ TableDescriptor pkTableNoStandby =
+ TableDescriptor.builder()
+ .schema(
+ Schema.newBuilder()
+ .column("a", DataTypes.INT())
+ .primaryKey("a")
+ .build())
+ .distributedBy(1, "a")
+ .build()
+ .withReplicationFactor(3);
+
+ int nBuckets = 1;
+ int replicationFactor = 3;
+ TableAssignment tableAssignment =
+ generateAssignment(
+ nBuckets,
+ replicationFactor,
+ new TabletServerInfo[] {
+ new TabletServerInfo(0, "rack0"),
+ new TabletServerInfo(1, "rack1"),
+ new TabletServerInfo(2, "rack2")
+ });
+ long tableId =
+ metadataManager.createTable(
+ t1, remoteDataDir, pkTableNoStandby, tableAssignment,
false);
+ TableBucket tableBucket = new TableBucket(tableId, 0);
+
+ // Wait for the bucket to become online with NO standby (legacy
behavior)
+ retryVerifyContext(
+ ctx -> {
+
assertThat(ctx.getBucketState(tableBucket)).isEqualTo(OnlineBucket);
+ LeaderAndIsr leaderAndIsr =
ctx.getBucketLeaderAndIsr(tableBucket).get();
+ assertThat(leaderAndIsr.standbyReplicas()).isEmpty();
+ });
+
+ // Record the leader epoch before ALTER
+ int leaderEpochBefore =
zookeeperClient.getLeaderAndIsr(tableBucket).get().leaderEpoch();
+
+ // ALTER: enable standby replica
+ TablePropertyChanges.Builder enableBuilder =
TablePropertyChanges.builder();
+ enableBuilder.setTableProperty(
+ ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED.key(), "true");
+ metadataManager.alterTableProperties(
+ t1, Collections.emptyList(), enableBuilder.build(), false,
null);
+
+ // Verify re-election happened: standby assigned and leaderEpoch
incremented
+ retryVerifyContext(
+ ctx -> {
+ TableInfo tableInfoInCtx = ctx.getTableInfoById(tableId);
+
assertThat(tableInfoInCtx.getTableConfig().isStandbyReplicaEnabled()).isTrue();
+ LeaderAndIsr leaderAndIsr =
ctx.getBucketLeaderAndIsr(tableBucket).get();
+ // Standby should now be assigned
+ assertThat(leaderAndIsr.standbyReplicas()).hasSize(1);
+ // Leader epoch should have incremented (proves
re-election)
+
assertThat(leaderAndIsr.leaderEpoch()).isGreaterThan(leaderEpochBefore);
+ });
+
+ // Also verify from ZooKeeper
+ LeaderAndIsr zkLeaderAndIsr =
zookeeperClient.getLeaderAndIsr(tableBucket).get();
+ assertThat(zkLeaderAndIsr.standbyReplicas()).hasSize(1);
+
assertThat(zkLeaderAndIsr.leaderEpoch()).isGreaterThan(leaderEpochBefore);
+ }
+
+ @Test
+ void testAlterStandbyReplicaEnabledForLogTable() throws Exception {
+ // Altering standby replica config on a log table (no PK) should throw
an error
+ initCoordinatorChannel();
+
+ TablePath t1 = TablePath.of(defaultDatabase,
"test_alter_standby_log_table");
+ TableDescriptor logTable =
+ TableDescriptor.builder()
+ .schema(
+ Schema.newBuilder()
+ .column("a", DataTypes.INT())
+ .column("b", DataTypes.STRING())
+ .build())
+ .distributedBy(1)
+ .build()
+ .withReplicationFactor(3);
+
+ int nBuckets = 1;
+ int replicationFactor = 3;
+ TableAssignment tableAssignment =
+ generateAssignment(
+ nBuckets,
+ replicationFactor,
+ new TabletServerInfo[] {
+ new TabletServerInfo(0, "rack0"),
+ new TabletServerInfo(1, "rack1"),
+ new TabletServerInfo(2, "rack2")
+ });
+ metadataManager.createTable(t1, remoteDataDir, logTable,
tableAssignment, false);
+
+ // ALTER: try to enable standby replica on a log table should fail
+ TablePropertyChanges.Builder enableBuilder =
TablePropertyChanges.builder();
+ enableBuilder.setTableProperty(
+ ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED.key(), "true");
+ assertThatThrownBy(
+ () ->
+ metadataManager.alterTableProperties(
+ t1,
+ Collections.emptyList(),
+ enableBuilder.build(),
+ false,
+ null))
+ .isInstanceOf(InvalidAlterTableException.class)
+ .hasMessageContaining("can only be altered on primary key
tables");
+
+ // ALTER: try to set to false on a log table should also fail
+ TablePropertyChanges.Builder disableBuilder =
TablePropertyChanges.builder();
+ disableBuilder.setTableProperty(
+ ConfigOptions.TABLE_KV_STANDBY_REPLICA_ENABLED.key(), "false");
+ assertThatThrownBy(
+ () ->
+ metadataManager.alterTableProperties(
+ t1,
+ Collections.emptyList(),
+ disableBuilder.build(),
+ false,
+ null))
+ .isInstanceOf(InvalidAlterTableException.class)
+ .hasMessageContaining("can only be altered on primary key
tables");
+ }
+
@Test
void testDoBucketReassignment() throws Exception {
zookeeperClient.registerTabletServer(
diff --git
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TableManagerITCase.java
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TableManagerITCase.java
index b2abac830..0428d4e10 100644
---
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TableManagerITCase.java
+++
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TableManagerITCase.java
@@ -497,7 +497,8 @@ class TableManagerITCase {
tableDescriptor = tableDescriptor.withProperties(properties);
assertThat(TableDescriptor.fromJsonBytes(tableMetadata.getTableJson()))
- .isEqualTo(tableDescriptor.withReplicationFactor(1));
+ .isEqualTo(
+
tableDescriptor.withReplicationFactor(1).withStandbyReplicaEnabled(true));
// now, check the table buckets metadata
assertThat(tableMetadata.getBucketMetadatasCount()).isEqualTo(expectBucketCount);
diff --git a/website/docs/engine-flink/options.md
b/website/docs/engine-flink/options.md
index af9f97a69..0b3d675dc 100644
--- a/website/docs/engine-flink/options.md
+++ b/website/docs/engine-flink/options.md
@@ -74,12 +74,13 @@ See more details about [ALTER TABLE ...
SET](engine-flink/ddl.md#set-properties)
| table.auto-partition.num-retention | Integer | 7
| The number of history partitions to retain for auto created
partitions in each check for auto partition. For example, if the current check
time is 2024-11-11, time-unit is DAY, and the value is configured as 3, then
the history partitions 20241108, 20241109, 20241110 will be retained. The
partitions earlier than 20241108 will be deleted. The default value is 7, which
means that 7 partitions will [...]
| table.auto-partition.time-zone | String | the system time zone
| The time zone for auto partitions, which is by default the same
as the system time zone.
[...]
| table.replication.factor | Integer | (None)
| The replication factor for the log of the new table. When it's
not set, Fluss will use the cluster's default replication factor configured by
default.replication.factor. It should be a positive number and not larger than
the number of tablet servers in the Fluss cluster. A value larger than the
number of tablet servers in Fluss cluster will result in an error when the new
table is created. [...]
-| table.statistics.columns | String | (None)
| Specifies which columns to collect statistics (min, max, null
count) for in log table batches. Use `*` to collect statistics for all
supported columns, or specify a comma-separated list of column names (e.g.,
`col1,col2`). Supported types: BOOLEAN, TINYINT, SMALLINT, INTEGER, BIGINT,
FLOAT, DOUBLE, STRING, CHAR, DECIMAL, DATE, TIME, TIMESTAMP, TIMESTAMP_LTZ.
Unsupported types (BYTES, BINARY, ARRA [...]
+| table.statistics.columns | String | (None)
| Specifies which columns to collect statistics (min, max, null
count) for in log table batches. Use `*` to collect statistics for all
supported columns, or specify a comma-separated list of column names (e.g.,
`col1,col2`). Supported types: BOOLEAN, TINYINT, SMALLINT, INTEGER, BIGINT,
FLOAT, DOUBLE, STRING, CHAR, DECIMAL, DATE, TIME, TIMESTAMP, TIMESTAMP_LTZ.
Unsupported types (BYTES, BINARY, ARRA [...]
| table.log.format | Enum | ARROW
| The format of the log records in log store. The default value is
`ARROW`. The supported formats are `ARROW`, `INDEXED` and `COMPACTED`.
[...]
| table.log.arrow.compression.type | Enum | ZSTD
| The compression type of the log records if the log format is set
to `ARROW`. The candidate compression type is `NONE`, `LZ4_FRAME`, `ZSTD`. The
default value is `ZSTD`.
[...]
| table.log.arrow.compression.zstd.level | Integer | 3
| The compression level of the log records if the log format is set
to `ARROW` and the compression type is set to `ZSTD`. The valid range is 1 to
22. The default value is 3.
[...]
| table.kv.format | Enum | COMPACTED
| The format of the kv records in kv store. The default value is
`COMPACTED`. The supported formats are `COMPACTED` and `INDEXED`.
[...]
| table.kv.format-version | Integer | (None)
| The version of the kv format. Automatically set by the
coordinator during table creation if not configured by users.<br></br>**Note:**
The datalake encoding and bucketing strategy mentioned below only takes effect
when `datalake.format` is configured at cluster level.<br></br>**Version
Behaviors:**<br></br>(1) **Version 1**: Tables created before
`table.kv.format-version` was introduced are treat [...]
+| table.kv.standby-replica.enabled | Boolean | (None)
| Whether to enable standby replicas for primary key tables.
Standby replicas maintain recent KV snapshots for fast leader promotion.
Automatically set to `true` by the coordinator during table creation for new PK
tables. Tables created before this option was introduced are treated as
disabled. Can be dynamically enabled via `ALTER TABLE SET
('table.kv.standby-replica.enabled' = 'true')`. [...]
| table.log.tiered.local-segments | Integer | 2
| The number of log segments to retain in local for each table when
log tiered storage is enabled. It must be greater that 0. The default is 2.
[...]
| table.datalake.enabled | Boolean | false
| Whether enable lakehouse storage for the table. Disabled by
default. When this option is set to ture and the datalake tiering service is
up, the table will be tiered and compacted into datalake format stored on
lakehouse storage.
[...]
| table.datalake.format | Enum | (None)
| The data lake format of the table specifies the tiered Lakehouse
storage format. Currently, supported formats are `paimon`, `iceberg`, and
`lance`. In the future, more kinds of data lake format will be supported, such
as DeltaLake or Hudi. Once the `table.datalake.format` property is configured,
Fluss adopts the key encoding and bucketing strategy used by the corresponding
data lake format. This [...]
@@ -91,7 +92,7 @@ See more details about [ALTER TABLE ...
SET](engine-flink/ddl.md#set-properties)
| table.delete.behavior | Enum | ALLOW
| Controls the behavior of delete operations on primary key tables.
Three modes are supported: `ALLOW` (default for default merge engine) - allows
normal delete operations; `IGNORE` - silently ignores delete requests without
errors; `DISABLE` - rejects delete requests and throws explicit errors. This
configuration provides system-level guarantees for some downstream pipelines
(e.g., Flink Delta Joi [...]
| table.changelog.image | Enum | FULL
| Defines the changelog image mode for primary key tables. This
configuration is inspired by similar settings in database systems like MySQL's
`binlog_row_image` and PostgreSQL's `replica identity`. Two modes are
supported: `FULL` (default) - produces both UPDATE_BEFORE and UPDATE_AFTER
records for update operations, capturing complete information about updates and
allowing tracking of previous val [...]
| table.auto-inc.batch-size | Long | 100000L
| The batch size of auto-increment IDs fetched from the distributed
counter each time. This value determines the length of the locally cached ID
segment. Default: 100000. A larger batch size may cause significant
auto-increment ID gaps, especially when unused cached ID segments are discarded
due to TabletServer restarts or abnormal terminations. Conversely, a smaller
batch size increases the freque [...]
-| table.statistics.columns | String | (None)
| Configures column-level statistics collection for the table. By
default this option is not set and no column statistics are collected. The
value `*` means collect statistics for all supported columns. A comma-separated
list of column names means collect statistics only for the specified columns
(recommended for minimal overhead). Supported types: BOOLEAN, TINYINT,
SMALLINT, INTEGER, BIGINT, FLOAT [...]
+| table.statistics.columns | String | (None)
| Configures column-level statistics collection for the table. By
default this option is not set and no column statistics are collected. The
value `*` means collect statistics for all supported columns. A comma-separated
list of column names means collect statistics only for the specified columns
(recommended for minimal overhead). Supported types: BOOLEAN, TINYINT,
SMALLINT, INTEGER, BIGINT, FLOAT [...]
## Read Options