From a6323b8685b444b2353ae8737a8e9881ff18edf6 Mon Sep 17 00:00:00 2001
From: Caideyipi <87789683+Caideyipi@users.noreply.github.com>
Date: Thu, 5 Nov 2026 13:18:34 +0800
Subject: [PATCH 1/2] Preserve accepted table attribute versions across schema
fetches
---
integration-test/pom.xml | 6 +
.../it/env/cluster/config/MppJVMConfig.java | 15 +
.../env/cluster/node/AbstractNodeWrapper.java | 1 +
.../IoTDBAttributeUpdateOrderingIT.java | 82 +++
.../TableMetadataPropagationTestBase.java | 431 +++++++++++++
.../it/schema/fault/MetadataFaultAgent.java | 600 ++++++++++++++++++
.../schema/fault/MetadataFaultController.java | 244 +++++++
.../TableDeviceCacheAttributeGuard.java | 3 +-
.../TableDeviceCacheAttributeGuardTest.java | 64 ++
9 files changed, 1445 insertions(+), 1 deletion(-)
create mode 100644 integration-test/src/test/java/org/apache/iotdb/relational/it/schema/IoTDBAttributeUpdateOrderingIT.java
create mode 100644 integration-test/src/test/java/org/apache/iotdb/relational/it/schema/TableMetadataPropagationTestBase.java
create mode 100644 integration-test/src/test/java/org/apache/iotdb/relational/it/schema/fault/MetadataFaultAgent.java
create mode 100644 integration-test/src/test/java/org/apache/iotdb/relational/it/schema/fault/MetadataFaultController.java
create mode 100644 iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/metadata/fetcher/TableDeviceCacheAttributeGuardTest.java
diff --git a/integration-test/pom.xml b/integration-test/pom.xml
index 779c059e2d649..c98043a37505a 100644
--- a/integration-test/pom.xml
+++ b/integration-test/pom.xml
@@ -92,6 +92,12 @@
3
+
+ org.ow2.asm
+ asm
+
+ runtime
+
org.apache.iotdb
iotdb-server
diff --git a/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppJVMConfig.java b/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppJVMConfig.java
index d9c51717cf8e1..7304e3024cd14 100644
--- a/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppJVMConfig.java
+++ b/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppJVMConfig.java
@@ -23,12 +23,26 @@
import javax.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
public class MppJVMConfig implements JVMConfig {
private int initHeapSize;
private int maxHeapSize;
private int maxDirectMemorySize;
private String timezone;
+ private List jvmOptions = Collections.emptyList();
+
+ /** Additional arguments for child test-node JVMs, for example a test-only Java agent. */
+ public void setJvmOptions(List options) {
+ this.jvmOptions = new ArrayList<>(options);
+ }
+
+ public List getJvmOptions() {
+ return Collections.unmodifiableList(jvmOptions);
+ }
@Override
public JVMConfig setInitHeapSize(int initSize) {
@@ -94,6 +108,7 @@ public void override(@Nullable MppJVMConfig config) {
this.setMaxHeapSize(config.getMaxHeapSize());
this.setMaxDirectMemorySize(config.getMaxDirectMemorySize());
this.setTimezone(config.getTimezone());
+ this.setJvmOptions(config.getJvmOptions());
}
public static Builder builder() {
diff --git a/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/node/AbstractNodeWrapper.java b/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/node/AbstractNodeWrapper.java
index 8d3f5f2cc9d9e..a5bc823eafda2 100644
--- a/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/node/AbstractNodeWrapper.java
+++ b/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/node/AbstractNodeWrapper.java
@@ -483,6 +483,7 @@ public void start() {
File stdoutFile = new File(getLogPath());
List startCmd = new ArrayList<>();
startCmd.add(JAVA_CMD);
+ startCmd.addAll(jvmConfig.getJvmOptions());
if (!SystemUtils.IS_JAVA_1_8) {
startCmd.add("--add-opens=java.base/java.util.concurrent=ALL-UNNAMED");
startCmd.add("--add-opens=java.base/java.lang=ALL-UNNAMED");
diff --git a/integration-test/src/test/java/org/apache/iotdb/relational/it/schema/IoTDBAttributeUpdateOrderingIT.java b/integration-test/src/test/java/org/apache/iotdb/relational/it/schema/IoTDBAttributeUpdateOrderingIT.java
new file mode 100644
index 0000000000000..df6bc662199c5
--- /dev/null
+++ b/integration-test/src/test/java/org/apache/iotdb/relational/it/schema/IoTDBAttributeUpdateOrderingIT.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iotdb.relational.it.schema;
+
+import org.apache.iotdb.it.env.cluster.node.DataNodeWrapper;
+import org.apache.iotdb.it.framework.IoTDBTestRunner;
+import org.apache.iotdb.itbase.category.TableClusterIT;
+import org.apache.iotdb.relational.it.schema.fault.MetadataFaultController.Node;
+
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(IoTDBTestRunner.class)
+@Category(TableClusterIT.class)
+public class IoTDBAttributeUpdateOrderingIT extends TableMetadataPropagationTestBase {
+ /** a delayed batch belongs to the dropped column's earlier lifetime. */
+ @Test
+ public void testOldAttributeBatchAfterDropAndReadd() throws Exception {
+ create("metadata_attribute_lifetime", 2);
+ DataNodeWrapper remote = remote();
+ Node target = NODES.get(remote);
+ warm(remote, 2);
+ Map lease = target.lease();
+ target.arm(UPDATE, "HOLD_FIRST", database, "changing=late-old;");
+ update("UPDATE sensor SET changing='late-old' WHERE device='d0'");
+ try {
+ target.awaitHeld(UPDATE);
+ Map delayed = target.status(UPDATE);
+ assertEquals("detail", delayed.get("batchType"));
+ assertTrue(delayed.toString(), delayed.get("version").matches("\\d+=\\d+"));
+ assertTrue(delayed.toString(), delayed.get("attributes").contains("changing=late-old"));
+ update("ALTER TABLE sensor DROP COLUMN changing");
+ update("ALTER TABLE sensor ADD COLUMN changing STRING ATTRIBUTE");
+ assertTwoDevices(null, null, true);
+ update("UPDATE sensor SET changing='new-lifetime' WHERE device='d0'");
+ awaitTwoDevices("new-lifetime", null, true);
+ assertEquals("0", target.status(UPDATE).get("finished"));
+ assertLease(target, lease);
+ target.release(UPDATE);
+ awaitFinished(target, UPDATE);
+ try {
+ assertTwoDevices("new-lifetime", null, true);
+ } catch (AssertionError e) {
+ throw new AssertionError(
+ "Completed old batch "
+ + delayed.get("version")
+ + " ("
+ + delayed.get("attributes")
+ + ") changed confirmed current data at "
+ + remote.getIpAndPortString(),
+ e);
+ }
+ update("UPDATE sensor SET changing='newer-lifetime' WHERE device='d0'");
+ awaitTwoDevices("newer-lifetime", null, true);
+ assertLease(target, lease);
+ } finally {
+ target.release(UPDATE);
+ }
+ }
+}
diff --git a/integration-test/src/test/java/org/apache/iotdb/relational/it/schema/TableMetadataPropagationTestBase.java b/integration-test/src/test/java/org/apache/iotdb/relational/it/schema/TableMetadataPropagationTestBase.java
new file mode 100644
index 0000000000000..04fc106807547
--- /dev/null
+++ b/integration-test/src/test/java/org/apache/iotdb/relational/it/schema/TableMetadataPropagationTestBase.java
@@ -0,0 +1,431 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iotdb.relational.it.schema;
+
+import org.apache.iotdb.common.rpc.thrift.TConsensusGroupType;
+import org.apache.iotdb.commons.schema.column.ColumnHeaderConstant;
+import org.apache.iotdb.confignode.i18n.ProcedureMessages;
+import org.apache.iotdb.consensus.ConsensusFactory;
+import org.apache.iotdb.db.it.utils.TestUtils;
+import org.apache.iotdb.isession.SessionConfig;
+import org.apache.iotdb.it.env.EnvFactory;
+import org.apache.iotdb.it.env.cluster.config.MppCommonConfig;
+import org.apache.iotdb.it.env.cluster.config.MppJVMConfig;
+import org.apache.iotdb.it.env.cluster.node.DataNodeWrapper;
+import org.apache.iotdb.it.framework.IoTDBTestRunner;
+import org.apache.iotdb.itbase.category.TableClusterIT;
+import org.apache.iotdb.itbase.env.BaseEnv;
+import org.apache.iotdb.relational.it.schema.fault.MetadataFaultController;
+import org.apache.iotdb.relational.it.schema.fault.MetadataFaultController.Node;
+import org.apache.iotdb.rpc.TSStatusCode;
+
+import org.awaitility.Awaitility;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Real remote attribute pulls, selective invalidation failures and delayed propagation batches. The
+ * two DataNodes share one Memory cluster; the non-replica is always the cache under test.
+ */
+@RunWith(IoTDBTestRunner.class)
+@Category(TableClusterIT.class)
+public abstract class TableMetadataPropagationTestBase {
+ protected static final String TABLE = "sensor";
+ protected static final String FILL = "putAttributes:enter";
+ protected static final String UPDATE = "updateAttribute:enter";
+ protected static final String INVALIDATE = "invalidateColumnCache:enter";
+ protected static final String ACK = "invalidateColumnCache:exit";
+ protected static final String DELETE = "deleteColumnData:enter";
+ protected static final String ACCEPT = "handleAttributeUpdate:exit";
+ protected static final String CONTAINER = "handleContainer:enter";
+ protected static final String OLD_COMMIT = "sendUpdateRequestAndMayShrink:exit";
+ protected static final Map NODES = new HashMap<>();
+ protected static MetadataFaultController faults;
+ protected String database;
+
+ @BeforeClass
+ public static void setUp() throws Exception {
+ EnvFactory.getEnv()
+ .getConfig()
+ .getCommonConfig()
+ .setSchemaEngineMode("Memory")
+ .setSchemaRegionConsensusProtocolClass(ConsensusFactory.RATIS_CONSENSUS)
+ .setSchemaReplicationFactor(1)
+ .setDataReplicationFactor(1)
+ .setSchemaRegionGroupExtensionPolicy("CUSTOM")
+ .setDataRegionGroupExtensionPolicy("CUSTOM")
+ .setDefaultSchemaRegionGroupNumPerDatabase(1)
+ .setDefaultDataRegionGroupNumPerDatabase(1)
+ .setMetadataLeaseFenceMs(8_000)
+ // At a fixed 512 MiB heap this gives SchemaRegion about 256 KiB. Cache memory remains
+ // sufficient for exact-device reads, so cache eviction cannot hide the propagation race.
+ .setSchemaMemoryAllocate("1:199:1");
+ EnvFactory.getEnv().getConfig().getDataNodeJVMConfig().setMaxHeapSize(512);
+ Properties properties = new Properties();
+ properties.setProperty("general_region_attribute_security_service_interval_seconds", "1");
+ properties.setProperty("general_region_attribute_security_service_timeout_seconds", "2");
+ properties.setProperty("detail_container_min_degrade_memory_in_bytes", "8192");
+ ((MppCommonConfig) EnvFactory.getEnv().getConfig().getDataNodeCommonConfig())
+ .updateProperties(properties);
+ faults =
+ new MetadataFaultController(
+ (MppJVMConfig) EnvFactory.getEnv().getConfig().getDataNodeJVMConfig());
+ try {
+ EnvFactory.getEnv().initClusterEnvironment(1, 2);
+ for (DataNodeWrapper wrapper : EnvFactory.getEnv().getDataNodeWrapperList()) {
+ NODES.put(wrapper, faults.connect(wrapper));
+ }
+ } finally {
+ faults.restoreLaunchEnvironment();
+ }
+ }
+
+ @AfterClass
+ public static void tearDown() throws Exception {
+ try {
+ EnvFactory.getEnv().cleanClusterEnvironment();
+ } finally {
+ if (faults != null) {
+ faults.close();
+ }
+ NODES.clear();
+ }
+ }
+
+ @After
+ public void cleanUp() throws Exception {
+ faults.reset();
+ if (database != null) {
+ try (Connection connection = EnvFactory.getEnv().getTableConnection();
+ Statement statement = connection.createStatement()) {
+ statement.execute("DROP DATABASE " + database);
+ }
+ }
+ }
+
+ protected void create(String name, int devices) throws Exception {
+ database = name;
+ try (Connection connection = connection(EnvFactory.getEnv().getDataNodeWrapperList().get(0));
+ Statement statement = connection.createStatement()) {
+ statement.execute("CREATE DATABASE " + database);
+ statement.execute("USE " + database);
+ statement.execute(
+ "CREATE TABLE sensor (device STRING TAG, changing STRING ATTRIBUTE, kept STRING ATTRIBUTE, reading INT32 FIELD)");
+ statement.execute(
+ "INSERT INTO sensor(time,device,changing,kept,reading) VALUES(1,'d0','old-0','keep-0',10)");
+ }
+ DataNodeWrapper remote = remote();
+ DataNodeWrapper owner =
+ EnvFactory.getEnv().getDataNodeWrapperList().stream()
+ .filter(wrapper -> wrapper != remote)
+ .findFirst()
+ .get();
+ // The remaining devices have never been fetched by the remote coordinator before its gate.
+ try (Connection connection = connection(owner);
+ Statement statement = connection.createStatement()) {
+ statement.execute("USE " + database);
+ for (int i = 1; i < devices; i++) {
+ statement.execute(
+ "INSERT INTO sensor(time,device,changing,kept,reading) VALUES("
+ + (i + 1)
+ + ",'d"
+ + i
+ + "','old-"
+ + i
+ + "','keep-"
+ + i
+ + "',"
+ + (i + 10)
+ + ")");
+ }
+ statement.execute("FLUSH");
+ }
+ }
+
+ protected DataNodeWrapper remote() throws SQLException {
+ DataNodeWrapper replica = null;
+ int regions = 0;
+ try (Connection connection = EnvFactory.getEnv().getTableConnection();
+ Statement statement = connection.createStatement();
+ ResultSet result = statement.executeQuery("SHOW REGIONS")) {
+ while (result.next()) {
+ if (TConsensusGroupType.SchemaRegion.toString()
+ .equals(result.getString(ColumnHeaderConstant.TYPE))
+ && database.equals(result.getString(ColumnHeaderConstant.DATABASE))) {
+ regions++;
+ replica =
+ EnvFactory.getEnv()
+ .dataNodeIdToWrapper(result.getInt(ColumnHeaderConstant.DATA_NODE_ID))
+ .get();
+ }
+ }
+ }
+ assertEquals("One sole SchemaRegion replica is required", 1, regions);
+ assertNotNull(replica);
+ for (DataNodeWrapper wrapper : EnvFactory.getEnv().getDataNodeWrapperList()) {
+ if (wrapper != replica) {
+ return wrapper;
+ }
+ }
+ throw new AssertionError("No remote cache node");
+ }
+
+ protected static Connection connection(DataNodeWrapper wrapper) throws SQLException {
+ return EnvFactory.getEnv()
+ .getConnection(
+ wrapper,
+ SessionConfig.DEFAULT_USER,
+ SessionConfig.DEFAULT_PASSWORD,
+ BaseEnv.TABLE_SQL_DIALECT);
+ }
+
+ protected void warm(DataNodeWrapper wrapper, int devices) throws Exception {
+ Node node = NODES.get(wrapper);
+ node.arm(FILL, "OBSERVE", database);
+ try (Connection connection = connection(wrapper);
+ Statement statement = connection.createStatement()) {
+ for (int i = 0; i < devices; i++) {
+ try (ResultSet result =
+ statement.executeQuery(
+ "SELECT changing, kept, reading FROM "
+ + database
+ + ".sensor WHERE device='d"
+ + i
+ + "'")) {
+ TestUtils.assertResultSetEqualUnordered(
+ result,
+ "changing,kept,reading,",
+ java.util.Collections.singletonList(
+ "old-" + i + ",keep-" + i + "," + (i + 10) + ","));
+ }
+ }
+ }
+ long fills = Long.parseLong(node.status(FILL).get("matches"));
+ assertTrue("The remote queries must actually fetch their attributes", fills >= devices - 1);
+ // The same complete keys must now hit the cache whose staleness is under test.
+ try (Connection connection = connection(wrapper);
+ Statement statement = connection.createStatement()) {
+ for (int i = 0; i < devices; i++) {
+ try (ResultSet result =
+ statement.executeQuery(
+ "SELECT changing FROM " + database + ".sensor WHERE device='d" + i + "'")) {
+ assertTrue(result.next());
+ assertEquals("old-" + i, result.getString(1));
+ assertFalse(result.next());
+ }
+ }
+ }
+ assertEquals(Long.toString(fills), node.status(FILL).get("matches"));
+ }
+
+ protected void update(String sql) throws Exception {
+ try (Connection connection = connection(EnvFactory.getEnv().getDataNodeWrapperList().get(0));
+ Statement statement = connection.createStatement()) {
+ statement.execute("USE " + database);
+ statement.execute(sql);
+ }
+ }
+
+ protected void assertTwoDevices(String first, String second, boolean readded) {
+ List attributes = new ArrayList<>();
+ attributes.add(first);
+ attributes.add(second);
+ assertDevices(attributes, readded);
+ }
+
+ protected void awaitTwoDevices(String first, String second, boolean readded) {
+ Awaitility.await()
+ .atMost(20, TimeUnit.SECONDS)
+ .untilAsserted(() -> assertTwoDevices(first, second, readded));
+ }
+
+ protected void assertDevices(List attributes, boolean readded) {
+ List data = new ArrayList<>();
+ List devices = new ArrayList<>();
+ int nulls = 0;
+ for (int i = 0; i < attributes.size(); i++) {
+ String attribute = attributes.get(i);
+ if (attribute == null) {
+ nulls++;
+ }
+ data.add((i + 1) + ",d" + i + "," + attribute + ",keep-" + i + "," + (i + 10) + ",");
+ devices.add(
+ "d"
+ + i
+ + ","
+ + (readded ? "keep-" + i + "," + attribute : attribute + ",keep-" + i)
+ + ",");
+ TestUtils.tableResultSetEqualTestUnordered(
+ "SELECT device, changing, kept, reading FROM sensor WHERE device='d" + i + "'",
+ new String[] {"device", "changing", "kept", "reading"},
+ new String[] {"d" + i + "," + attribute + ",keep-" + i + "," + (i + 10) + ","},
+ database);
+ }
+ TestUtils.tableResultSetEqualTestUnordered(
+ "SELECT CAST(time AS INT64) AS time, device, changing, kept, reading FROM sensor",
+ new String[] {"time", "device", "changing", "kept", "reading"},
+ data.toArray(new String[0]),
+ database);
+ TestUtils.tableResultSetEqualTestUnordered(
+ "SHOW DEVICES FROM sensor",
+ readded
+ ? new String[] {"device", "kept", "changing"}
+ : new String[] {"device", "changing", "kept"},
+ devices.toArray(new String[0]),
+ database);
+ TestUtils.tableResultSetEqualTestUnordered(
+ "COUNT DEVICES FROM sensor",
+ new String[] {"count(devices)"},
+ new String[] {attributes.size() + ","},
+ database);
+ TestUtils.tableResultSetEqualTestUnordered(
+ "COUNT DEVICES FROM sensor WHERE changing IS NULL",
+ new String[] {"count(devices)"},
+ new String[] {nulls + ","},
+ database);
+ if (attributes.get(0) != null) {
+ assertAttributeFilter(0, attributes.get(0));
+ }
+ TestUtils.tableResultSetEqualTestUnordered(
+ "COUNT DEVICES FROM sensor WHERE changing='late-old'",
+ new String[] {"count(devices)"},
+ new String[] {"0,"},
+ database);
+ TestUtils.tableResultSetEqualTest(
+ "DESCRIBE sensor",
+ new String[] {"ColumnName", "DataType", "Category"},
+ readded
+ ? new String[] {
+ "time,TIMESTAMP,TIME,",
+ "device,STRING,TAG,",
+ "kept,STRING,ATTRIBUTE,",
+ "reading,INT32,FIELD,",
+ "changing,STRING,ATTRIBUTE,"
+ }
+ : new String[] {
+ "time,TIMESTAMP,TIME,",
+ "device,STRING,TAG,",
+ "changing,STRING,ATTRIBUTE,",
+ "kept,STRING,ATTRIBUTE,",
+ "reading,INT32,FIELD,"
+ },
+ database);
+ }
+
+ protected void assertInvalidationFailure(SQLException failure) {
+ assertNotNull("DDL crossed the live node without an ACK", failure);
+ assertEquals(
+ failure.toString(), TSStatusCode.METADATA_ERROR.getStatusCode(), failure.getErrorCode());
+ assertTrue(
+ failure.toString(),
+ failure
+ .getMessage()
+ .contains(
+ String.format(
+ ProcedureMessages.INVALIDATE_COLUMN_CACHE_FAILED_FOR_TABLE,
+ "changing",
+ database,
+ TABLE)));
+ }
+
+ protected void assertAttributeFilter(int device, String attribute) {
+ String predicate =
+ "device='d"
+ + device
+ + "' AND "
+ + (attribute == null ? "changing IS NULL" : "changing='" + attribute + "'");
+ TestUtils.tableResultSetEqualTestUnordered(
+ "COUNT DEVICES FROM sensor WHERE " + predicate,
+ new String[] {"count(devices)"},
+ new String[] {"1,"},
+ database);
+ // Check nullable data predicates after the complete fault/recovery sequence. A query failure
+ // must still fail the test, without preventing the delayed batch or later DDL from being
+ // tested.
+ TestUtils.tableResultSetEqualTestUnordered(
+ "SELECT device, kept, reading FROM sensor WHERE " + predicate,
+ new String[] {"device", "kept", "reading"},
+ new String[] {"d" + device + ",keep-" + device + "," + (device + 10) + ","},
+ database);
+ }
+
+ protected static void assertLease(Node node, Map initial) throws Exception {
+ Map current = node.lease();
+ assertEquals(current.toString(), "false", current.get("fenced"));
+ assertEquals(
+ "Lease recovery must not clear the cache", initial.get("clears"), current.get("clears"));
+ assertTrue(
+ current.toString(),
+ Long.parseLong(current.get("elapsed")) < Long.parseLong(current.get("threshold")));
+ assertTrue(
+ current.toString(),
+ Long.parseLong(current.get("heartbeats")) >= Long.parseLong(initial.get("heartbeats")));
+ }
+
+ protected static void awaitMatches(Node node, String hook) {
+ Awaitility.await()
+ .atMost(20, TimeUnit.SECONDS)
+ .until(() -> Boolean.parseBoolean(node.status(hook).get("captured")));
+ }
+
+ protected static void awaitFinished(Node node, String hook) {
+ Awaitility.await()
+ .atMost(10, TimeUnit.SECONDS)
+ .until(() -> Long.parseLong(node.status(hook).get("completed")) > 0);
+ }
+
+ protected void awaitEmpty(Node sender) {
+ Awaitility.await()
+ .atMost(20, TimeUnit.SECONDS)
+ .untilAsserted(
+ () ->
+ assertEquals(
+ sender.updaters(database).toString(),
+ "0",
+ sender.updaters(database).get("pending")));
+ }
+
+ protected static void finish(ExecutorService executor) throws InterruptedException {
+ executor.shutdown();
+ if (!executor.awaitTermination(35, TimeUnit.SECONDS)) {
+ executor.shutdownNow();
+ assertTrue("Test executor did not stop", executor.awaitTermination(5, TimeUnit.SECONDS));
+ }
+ }
+}
diff --git a/integration-test/src/test/java/org/apache/iotdb/relational/it/schema/fault/MetadataFaultAgent.java b/integration-test/src/test/java/org/apache/iotdb/relational/it/schema/fault/MetadataFaultAgent.java
new file mode 100644
index 0000000000000..fdcde9766bc9a
--- /dev/null
+++ b/integration-test/src/test/java/org/apache/iotdb/relational/it/schema/fault/MetadataFaultAgent.java
@@ -0,0 +1,600 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iotdb.relational.it.schema.fault;
+
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.lang.instrument.ClassFileTransformer;
+import java.lang.instrument.Instrumentation;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.ProtectionDomain;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Launch-time instrumentation for metadata IT child processes only. No production classes are
+ * replaced on disk. The gates run before the receiver takes its locks, or after its finally block.
+ * The control channel only arms gates and reads state; it never edits product caches or versions.
+ */
+public final class MetadataFaultAgent {
+ public static final String INVALIDATE = "invalidateColumnCache";
+ public static final String UPDATE = "updateAttribute";
+ public static final String FILL = "putAttributes";
+ private static final String RPC =
+ "org/apache/iotdb/db/protocol/thrift/impl/DataNodeInternalRPCServiceImpl";
+ private static final String FETCHER =
+ "org/apache/iotdb/db/queryengine/plan/relational/metadata/fetcher/";
+ private static final String UPDATER =
+ "org/apache/iotdb/db/schemaengine/schemaregion/attribute/update/";
+ private static final String LEASE = "org/apache/iotdb/db/schemaengine/lease/MetadataLeaseManager";
+ private static final Map RULES = new ConcurrentHashMap<>();
+ private static final Map COUNTS = new ConcurrentHashMap<>();
+ private static final Set