diff --git a/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppCommonConfig.java b/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppCommonConfig.java index 3bba46f798c79..a4fdcacafd20a 100644 --- a/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppCommonConfig.java +++ b/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppCommonConfig.java @@ -477,6 +477,21 @@ public CommonConfig setIsPipeEnableMemoryCheck(boolean isPipeEnableMemoryCheck) return this; } + @Override + public CommonConfig setSubscriptionEnabled(boolean subscriptionEnabled) { + setProperty("subscription_enabled", String.valueOf(subscriptionEnabled)); + return this; + } + + @Override + public CommonConfig setSubscriptionOwnerLeaseDurationMsMin( + long subscriptionOwnerLeaseDurationMsMin) { + setProperty( + "subscription_owner_lease_duration_ms_min", + String.valueOf(subscriptionOwnerLeaseDurationMsMin)); + return this; + } + @Override public CommonConfig setPipeAirGapReceiverEnabled(boolean isPipeAirGapReceiverEnabled) { setProperty("pipe_air_gap_receiver_enabled", String.valueOf(isPipeAirGapReceiverEnabled)); diff --git a/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppSharedCommonConfig.java b/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppSharedCommonConfig.java index 582c9a049e492..83e80176b8da4 100644 --- a/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppSharedCommonConfig.java +++ b/integration-test/src/main/java/org/apache/iotdb/it/env/cluster/config/MppSharedCommonConfig.java @@ -493,6 +493,21 @@ public CommonConfig setIsPipeEnableMemoryCheck(boolean isPipeEnableMemoryCheck) return this; } + @Override + public CommonConfig setSubscriptionEnabled(boolean subscriptionEnabled) { + dnConfig.setSubscriptionEnabled(subscriptionEnabled); + cnConfig.setSubscriptionEnabled(subscriptionEnabled); + return this; + } + + @Override + public CommonConfig setSubscriptionOwnerLeaseDurationMsMin( + long subscriptionOwnerLeaseDurationMsMin) { + dnConfig.setSubscriptionOwnerLeaseDurationMsMin(subscriptionOwnerLeaseDurationMsMin); + cnConfig.setSubscriptionOwnerLeaseDurationMsMin(subscriptionOwnerLeaseDurationMsMin); + return this; + } + @Override public CommonConfig setPipeAirGapReceiverEnabled(boolean isPipeAirGapReceiverEnabled) { dnConfig.setPipeAirGapReceiverEnabled(isPipeAirGapReceiverEnabled); diff --git a/integration-test/src/main/java/org/apache/iotdb/it/env/remote/config/RemoteCommonConfig.java b/integration-test/src/main/java/org/apache/iotdb/it/env/remote/config/RemoteCommonConfig.java index 48c157e957be8..e4b55cb91bb54 100644 --- a/integration-test/src/main/java/org/apache/iotdb/it/env/remote/config/RemoteCommonConfig.java +++ b/integration-test/src/main/java/org/apache/iotdb/it/env/remote/config/RemoteCommonConfig.java @@ -348,6 +348,17 @@ public CommonConfig setIsPipeEnableMemoryCheck(boolean isPipeEnableMemoryCheck) return this; } + @Override + public CommonConfig setSubscriptionEnabled(boolean subscriptionEnabled) { + return this; + } + + @Override + public CommonConfig setSubscriptionOwnerLeaseDurationMsMin( + long subscriptionOwnerLeaseDurationMsMin) { + return this; + } + @Override public CommonConfig setPipeAirGapReceiverEnabled(boolean isPipeAirGapReceiverEnabled) { return this; diff --git a/integration-test/src/main/java/org/apache/iotdb/itbase/env/CommonConfig.java b/integration-test/src/main/java/org/apache/iotdb/itbase/env/CommonConfig.java index dc21234e2bad2..3ee069d0ee323 100644 --- a/integration-test/src/main/java/org/apache/iotdb/itbase/env/CommonConfig.java +++ b/integration-test/src/main/java/org/apache/iotdb/itbase/env/CommonConfig.java @@ -154,6 +154,10 @@ CommonConfig setEnableAutoLeaderBalanceForIoTConsensus( CommonConfig setIsPipeEnableMemoryCheck(boolean isPipeEnableMemoryCheck); + CommonConfig setSubscriptionEnabled(boolean subscriptionEnabled); + + CommonConfig setSubscriptionOwnerLeaseDurationMsMin(long subscriptionOwnerLeaseDurationMsMin); + CommonConfig setPipeAirGapReceiverEnabled(boolean isPipeAirGapReceiverEnabled); CommonConfig setDriverTaskExecutionTimeSliceInMs(long driverTaskExecutionTimeSliceInMs); diff --git a/integration-test/src/test/java/org/apache/iotdb/subscription/it/cluster/IoTDBSubscriptionTopicOwnerPartitionIT.java b/integration-test/src/test/java/org/apache/iotdb/subscription/it/cluster/IoTDBSubscriptionTopicOwnerPartitionIT.java new file mode 100644 index 0000000000000..31fa3fde02276 --- /dev/null +++ b/integration-test/src/test/java/org/apache/iotdb/subscription/it/cluster/IoTDBSubscriptionTopicOwnerPartitionIT.java @@ -0,0 +1,267 @@ +/* + * 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.subscription.it.cluster; + +import org.apache.iotdb.isession.ISession; +import org.apache.iotdb.it.env.EnvFactory; +import org.apache.iotdb.it.framework.IoTDBTestRunner; +import org.apache.iotdb.itbase.category.ClusterIT; +import org.apache.iotdb.rpc.subscription.config.TopicConstant; +import org.apache.iotdb.rpc.subscription.exception.SubscriptionOwnerFencedException; +import org.apache.iotdb.session.subscription.SubscriptionTreeSession; +import org.apache.iotdb.session.subscription.consumer.tree.SubscriptionTreePullConsumer; +import org.apache.iotdb.session.subscription.model.Topic; +import org.apache.iotdb.subscription.it.AbstractSubscriptionIT; +import org.apache.iotdb.subscription.it.IoTDBSubscriptionITConstant; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.runner.RunWith; + +import java.util.Properties; + +/** + * Owner-fencing correctness under owner/DataNode/ConfigNode partition topologies, simulated at the + * node-process level via the IT framework (shutdown/start of ConfigNodes and DataNodes; ConfigNode + * failover via leader shutdown). Runs on a 3 ConfigNode + 3 DataNode cluster. + * + *

Covers: + * + *

+ */ +@RunWith(IoTDBTestRunner.class) +@Category({ClusterIT.class}) +public class IoTDBSubscriptionTopicOwnerPartitionIT extends AbstractSubscriptionIT { + + private static final String HEAD_PATH = "root.topic_owner_partition"; + + @Override + @Before + public void setUp() throws Exception { + super.setUp(); + EnvFactory.getEnv() + .getConfig() + .getCommonConfig() + .setSubscriptionEnabled(true) + .setPipeMemoryManagementEnabled(false) + .setIsPipeEnableMemoryCheck(false) + // Lower the owner-lease floor so the test can use a short lease and stay fast. + .setSubscriptionOwnerLeaseDurationMsMin(1000); + // 3 ConfigNodes (for failover) + 3 DataNodes (for multi-DataNode data placement). + EnvFactory.getEnv().initClusterEnvironment(3, 3); + } + + @Override + @After + public void tearDown() throws Exception { + EnvFactory.getEnv().cleanClusterEnvironment(); + super.tearDown(); + } + + @Test + public void testStaleOwnerFencedOnMultiDataNodeTopic() throws Exception { + final String host = EnvFactory.getEnv().getIP(); + final int port = Integer.parseInt(EnvFactory.getEnv().getPort()); + final String topicName = "topic_owner_multi_dn"; + + createOwnedTopic(host, port, topicName, "owner2", 6L); + try { + // Spread data across multiple devices (and thus, with multiple data regions, multiple + // DataNodes) before consuming. + insertDataAcrossDevices(); + + // A stale owner (epoch 5 < current 6) must be fenced regardless of which DataNode serves the + // request. + assertSubscribeFenced(host, port, topicName, "stale_multi_dn", "owner1", 5L); + + // The current owner can subscribe. + try (final SubscriptionTreePullConsumer currentOwnerConsumer = + ownerConsumer( + host, port, "current_multi_dn", "topic_owner_multi_dn_group", "owner2", 6L)) { + currentOwnerConsumer.open(); + currentOwnerConsumer.subscribe(topicName); + currentOwnerConsumer.unsubscribe(topicName); + } + } finally { + dropTopic(host, port, topicName); + } + } + + @Test + public void testConfigNodeFailoverPersistsOwnerEpochAndKeepsFencing() throws Exception { + final String host = EnvFactory.getEnv().getIP(); + final int port = Integer.parseInt(EnvFactory.getEnv().getPort()); + final String topicName = "topic_owner_cn_failover"; + final long ownerLeaseDurationMs = 2000L; + + try (final SubscriptionTreeSession session = new SubscriptionTreeSession(host, port)) { + session.open(); + final Properties properties = new Properties(); + properties.put(TopicConstant.PATH_KEY, HEAD_PATH + ".**"); + properties.put(TopicConstant.START_TIME_KEY, "0"); + properties.put(TopicConstant.OWNER_ID_KEY, "owner1"); + properties.put(TopicConstant.OWNER_EPOCH_KEY, "5"); + properties.put( + TopicConstant.OWNER_LEASE_DURATION_MS_KEY, String.valueOf(ownerLeaseDurationMs)); + session.createTopic(topicName, properties); + + // Transfer to owner2/epoch 6 (this persists owner + epoch via ConfigNode consensus). + session.alterTopicOwner(topicName, "owner2", 6L); + Assert.assertTrue(getTopicAttributes(session, topicName).contains("owner-epoch=6")); + } + + // Fail over: shut down the current leader ConfigNode. + final int leaderIndex = EnvFactory.getEnv().getLeaderConfigNodeIndex(); + EnvFactory.getEnv().shutdownConfigNode(leaderIndex); + + try { + // After failover, the new leader must still report the persisted owner/epoch (consensus), and + // a stale owner (epoch 5) must remain fenced. + IoTDBSubscriptionITConstant.AWAIT.untilAsserted( + () -> { + try (final SubscriptionTreeSession session = new SubscriptionTreeSession(host, port)) { + session.open(); + final String attributes = getTopicAttributes(session, topicName); + Assert.assertTrue(attributes.contains("owner-id=owner2")); + Assert.assertTrue(attributes.contains("owner-epoch=6")); + } + }); + assertSubscribeFenced(host, port, topicName, "stale_after_failover", "owner1", 5L); + } finally { + EnvFactory.getEnv().startConfigNode(leaderIndex); + dropTopic(host, port, topicName); + } + } + + @Test + public void testDataNodeRestartReSyncsOwnerFencing() throws Exception { + final String host = EnvFactory.getEnv().getIP(); + final int port = Integer.parseInt(EnvFactory.getEnv().getPort()); + final String topicName = "topic_owner_dn_restart"; + + createOwnedTopic(host, port, topicName, "owner2", 6L); + try { + // Restart a DataNode: it loses in-memory owner metadata and must re-sync it on restart. + EnvFactory.getEnv().shutdownDataNode(1); + EnvFactory.getEnv().startDataNode(1); + + // After the DataNode comes back and re-syncs owner metadata, the stale owner stays fenced. + IoTDBSubscriptionITConstant.AWAIT.untilAsserted( + () -> + assertSubscribeFenced(host, port, topicName, "stale_after_dn_restart", "owner1", 5L)); + } finally { + dropTopic(host, port, topicName); + } + } + + private static void createOwnedTopic( + final String host, + final int port, + final String topicName, + final String ownerId, + final long ownerEpoch) + throws Exception { + try (final SubscriptionTreeSession session = new SubscriptionTreeSession(host, port)) { + session.open(); + final Properties properties = new Properties(); + properties.put(TopicConstant.PATH_KEY, HEAD_PATH + ".**"); + properties.put(TopicConstant.START_TIME_KEY, "0"); + properties.put(TopicConstant.OWNER_ID_KEY, ownerId); + properties.put(TopicConstant.OWNER_EPOCH_KEY, String.valueOf(ownerEpoch)); + session.createTopic(topicName, properties); + } + } + + private static void dropTopic(final String host, final int port, final String topicName) { + try (final SubscriptionTreeSession session = new SubscriptionTreeSession(host, port)) { + session.open(); + session.dropTopicIfExists(topicName); + } catch (final Exception e) { + // best-effort cleanup + } + } + + private static SubscriptionTreePullConsumer ownerConsumer( + final String host, + final int port, + final String consumerId, + final String consumerGroupId, + final String ownerId, + final long ownerEpoch) { + return new SubscriptionTreePullConsumer.Builder() + .host(host) + .port(port) + .consumerId(consumerId) + .consumerGroupId(consumerGroupId) + .ownerId(ownerId) + .ownerEpoch(ownerEpoch) + .autoCommit(false) + .buildPullConsumer(); + } + + private static void assertSubscribeFenced( + final String host, + final int port, + final String topicName, + final String consumerId, + final String ownerId, + final long ownerEpoch) + throws Exception { + try (final SubscriptionTreePullConsumer staleOwnerConsumer = + ownerConsumer(host, port, consumerId, topicName + "_stale_group", ownerId, ownerEpoch)) { + staleOwnerConsumer.open(); + Assert.assertThrows( + SubscriptionOwnerFencedException.class, () -> staleOwnerConsumer.subscribe(topicName)); + } + } + + private void insertDataAcrossDevices() throws Exception { + try (final ISession session = EnvFactory.getEnv().getSessionConnection()) { + for (int device = 0; device < 6; device++) { + for (int i = 0; i < 10; i++) { + session.executeNonQueryStatement( + String.format( + "insert into %s.d%s(time, s1) values (%s, %s)", HEAD_PATH, device, i, i)); + } + } + session.executeNonQueryStatement("flush"); + } + } + + private static String getTopicAttributes( + final SubscriptionTreeSession session, final String topicName) throws Exception { + for (final Topic topic : session.getTopics()) { + if (topicName.equals(topic.getTopicName())) { + return topic.getTopicAttributes(); + } + } + Assert.fail("Topic " + topicName + " should exist."); + return ""; + } +} diff --git a/integration-test/src/test/java/org/apache/iotdb/subscription/it/local/AbstractSubscriptionLocalIT.java b/integration-test/src/test/java/org/apache/iotdb/subscription/it/local/AbstractSubscriptionLocalIT.java index 9ee6a8d4e8d0d..fe667480c86bf 100644 --- a/integration-test/src/test/java/org/apache/iotdb/subscription/it/local/AbstractSubscriptionLocalIT.java +++ b/integration-test/src/test/java/org/apache/iotdb/subscription/it/local/AbstractSubscriptionLocalIT.java @@ -36,6 +36,7 @@ public void setUp() throws Exception { EnvFactory.getEnv() .getConfig() .getCommonConfig() + .setSubscriptionEnabled(true) .setPipeMemoryManagementEnabled(false) .setIsPipeEnableMemoryCheck(false); diff --git a/integration-test/src/test/java/org/apache/iotdb/subscription/it/local/IoTDBSubscriptionTopicOwnerIT.java b/integration-test/src/test/java/org/apache/iotdb/subscription/it/local/IoTDBSubscriptionTopicOwnerIT.java new file mode 100644 index 0000000000000..c08f151c16f49 --- /dev/null +++ b/integration-test/src/test/java/org/apache/iotdb/subscription/it/local/IoTDBSubscriptionTopicOwnerIT.java @@ -0,0 +1,225 @@ +/* + * 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.subscription.it.local; + +import org.apache.iotdb.isession.ISession; +import org.apache.iotdb.it.env.EnvFactory; +import org.apache.iotdb.it.framework.IoTDBTestRunner; +import org.apache.iotdb.itbase.category.LocalStandaloneIT; +import org.apache.iotdb.rpc.subscription.config.TopicConstant; +import org.apache.iotdb.rpc.subscription.exception.SubscriptionOwnerFencedException; +import org.apache.iotdb.session.subscription.SubscriptionTreeSession; +import org.apache.iotdb.session.subscription.consumer.tree.SubscriptionTreePullConsumer; +import org.apache.iotdb.session.subscription.model.Topic; +import org.apache.iotdb.session.subscription.payload.SubscriptionMessage; +import org.apache.iotdb.session.subscription.payload.SubscriptionRecordHandler; +import org.apache.iotdb.subscription.it.AbstractSubscriptionIT; +import org.apache.iotdb.subscription.it.IoTDBSubscriptionITConstant; + +import org.apache.tsfile.read.query.dataset.ResultSet; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.runner.RunWith; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicReference; + +@RunWith(IoTDBTestRunner.class) +@Category({LocalStandaloneIT.class}) +public class IoTDBSubscriptionTopicOwnerIT extends AbstractSubscriptionIT { + + @Override + @Before + public void setUp() throws Exception { + super.setUp(); + EnvFactory.getEnv() + .getConfig() + .getCommonConfig() + .setSubscriptionEnabled(true) + .setPipeMemoryManagementEnabled(false) + .setIsPipeEnableMemoryCheck(false) + // Lower the owner-lease floor so the drain test can use a short lease and stay fast. + .setSubscriptionOwnerLeaseDurationMsMin(1000); + EnvFactory.getEnv().initClusterEnvironment(); + } + + @Override + @After + public void tearDown() throws Exception { + EnvFactory.getEnv().cleanClusterEnvironment(); + super.tearDown(); + } + + @Test + public void testTopicOwnerFencingRejectsStaleOwnerAndAllowsCurrentOwner() throws Exception { + final String host = EnvFactory.getEnv().getIP(); + final int port = Integer.parseInt(EnvFactory.getEnv().getPort()); + final String topicName = "topic_owner_fencing"; + + try (final SubscriptionTreeSession session = new SubscriptionTreeSession(host, port)) { + session.open(); + final Properties properties = new Properties(); + properties.put(TopicConstant.PATH_KEY, "root.topic_owner.**"); + properties.put(TopicConstant.START_TIME_KEY, "0"); + properties.put(TopicConstant.OWNER_ID_KEY, "owner2"); + properties.put(TopicConstant.OWNER_EPOCH_KEY, "6"); + session.createTopic(topicName, properties); + } + + try { + try (final SubscriptionTreePullConsumer staleOwnerConsumer = + new SubscriptionTreePullConsumer.Builder() + .host(host) + .port(port) + .consumerId("stale_owner") + .consumerGroupId("topic_owner_group") + .ownerId("owner1") + .ownerEpoch(5L) + .autoCommit(false) + .buildPullConsumer()) { + staleOwnerConsumer.open(); + Assert.assertThrows( + SubscriptionOwnerFencedException.class, () -> staleOwnerConsumer.subscribe(topicName)); + } + + try (final SubscriptionTreePullConsumer currentOwnerConsumer = + new SubscriptionTreePullConsumer.Builder() + .host(host) + .port(port) + .consumerId("current_owner") + .consumerGroupId("topic_owner_group") + .ownerId("owner2") + .ownerEpoch(6L) + .autoCommit(false) + .buildPullConsumer()) { + currentOwnerConsumer.open(); + currentOwnerConsumer.subscribe(topicName); + + insertData(); + + final AtomicReference> polledMessages = + new AtomicReference<>(Collections.emptyList()); + IoTDBSubscriptionITConstant.AWAIT.untilAsserted( + () -> { + final List messages = + currentOwnerConsumer.poll(Duration.ofMillis(1000)); + polledMessages.set(messages); + Assert.assertFalse(messages.isEmpty()); + Assert.assertTrue(countRows(messages) > 0); + }); + + currentOwnerConsumer.commitSync(polledMessages.get()); + currentOwnerConsumer.unsubscribe(topicName); + } + } finally { + try (final SubscriptionTreeSession session = new SubscriptionTreeSession(host, port)) { + session.open(); + session.dropTopicIfExists(topicName); + } + } + } + + @Test + public void testAlterTopicOwnerWaitsForLeaseDrainBeforeInstallingNewOwner() throws Exception { + final String host = EnvFactory.getEnv().getIP(); + final int port = Integer.parseInt(EnvFactory.getEnv().getPort()); + final String topicName = "topic_owner_lease_drain"; + final long ownerLeaseDurationMs = 2500L; + + try (final SubscriptionTreeSession session = new SubscriptionTreeSession(host, port)) { + session.open(); + final Properties properties = new Properties(); + properties.put(TopicConstant.PATH_KEY, "root.topic_owner.**"); + properties.put(TopicConstant.START_TIME_KEY, "0"); + properties.put(TopicConstant.OWNER_ID_KEY, "owner1"); + properties.put(TopicConstant.OWNER_EPOCH_KEY, "5"); + properties.put( + TopicConstant.OWNER_LEASE_DURATION_MS_KEY, String.valueOf(ownerLeaseDurationMs)); + session.createTopic(topicName, properties); + + try { + // Transferring to a different owner must wait for the old owner's lease to drain on every + // DataNode before the new owner is installed. ConfigNode stops renewing and then waits, on + // its own clock, for the lease duration plus one renewal (heartbeat) interval. The extra + // heartbeat interval is necessary because a renewal may have been in flight to a DataNode + // right before renewal was stopped, extending that DataNode's lease by up to the full + // duration starting as late as ~one heartbeat after the block; waiting (duration + one + // interval) on the local clock — rather than comparing against a DataNode's absolute + // expire time, which would couple the two clocks — guarantees every lease has drained. The + // call therefore blocks for at least the lease duration. This is the admission gate that + // prevents cross-DataNode double-active consuming. + final long alterStartTimeMs = System.currentTimeMillis(); + session.alterTopicOwner(topicName, "owner2", 6L); + final long alterElapsedTimeMs = System.currentTimeMillis() - alterStartTimeMs; + Assert.assertTrue( + "alterTopicOwner should block at least the lease drain duration, but only took " + + alterElapsedTimeMs + + "ms", + alterElapsedTimeMs >= ownerLeaseDurationMs); + + final String topicAttributes = getTopicAttributes(session, topicName); + Assert.assertTrue(topicAttributes.contains("owner-id=owner2")); + Assert.assertTrue(topicAttributes.contains("owner-epoch=6")); + } finally { + session.dropTopicIfExists(topicName); + } + } + } + + private void insertData() throws Exception { + try (final ISession session = EnvFactory.getEnv().getSessionConnection()) { + for (int i = 0; i < 10; i++) { + session.executeNonQueryStatement( + String.format("insert into root.topic_owner.d1(time, s1) values (%s, %s)", i, i)); + } + session.executeNonQueryStatement("flush"); + } + } + + private static int countRows(final List messages) throws Exception { + int rowCount = 0; + for (final SubscriptionMessage message : messages) { + for (final ResultSet resultSet : message.getResultSets()) { + while (((SubscriptionRecordHandler.SubscriptionResultSet) resultSet).hasNext()) { + resultSet.next(); + rowCount++; + } + } + } + return rowCount; + } + + private static String getTopicAttributes( + final SubscriptionTreeSession session, final String topicName) throws Exception { + for (final Topic topic : session.getTopics()) { + if (topicName.equals(topic.getTopicName())) { + return topic.getTopicAttributes(); + } + } + Assert.fail("Topic " + topicName + " should exist."); + return ""; + } +} diff --git a/iotdb-client/service-rpc/src/main/java/org/apache/iotdb/rpc/TSStatusCode.java b/iotdb-client/service-rpc/src/main/java/org/apache/iotdb/rpc/TSStatusCode.java index d22e8f5520776..0c50d11535145 100644 --- a/iotdb-client/service-rpc/src/main/java/org/apache/iotdb/rpc/TSStatusCode.java +++ b/iotdb-client/service-rpc/src/main/java/org/apache/iotdb/rpc/TSStatusCode.java @@ -318,6 +318,11 @@ public enum TSStatusCode { SUBSCRIPTION_PIPE_TIMEOUT_ERROR(1911), SUBSCRIPTION_NOT_ENABLED_ERROR(1912), SUBSCRIPTION_SEEK_ERROR(1913), + SUBSCRIPTION_OWNER_FENCED(1914), + SUBSCRIPTION_OWNER_REQUIRED(1915), + SUBSCRIPTION_OWNER_EPOCH_REQUIRED(1916), + SUBSCRIPTION_OWNER_LEASE_EXPIRED(1917), + SUBSCRIPTION_OWNER_EPOCH_CONFLICT(1918), // Topic CREATE_TOPIC_ERROR(2000), diff --git a/iotdb-client/subscription/src/main/i18n/en/org/apache/iotdb/rpc/subscription/i18n/SubscriptionMessages.java b/iotdb-client/subscription/src/main/i18n/en/org/apache/iotdb/rpc/subscription/i18n/SubscriptionMessages.java index 3b03471138da9..f19f35b705e6d 100644 --- a/iotdb-client/subscription/src/main/i18n/en/org/apache/iotdb/rpc/subscription/i18n/SubscriptionMessages.java +++ b/iotdb-client/subscription/src/main/i18n/en/org/apache/iotdb/rpc/subscription/i18n/SubscriptionMessages.java @@ -91,5 +91,30 @@ public final class SubscriptionMessages { public static final String CONSUMER_SUBMIT_ENDPOINTS_SYNCER = "SubscriptionConsumer {} submit endpoints syncer"; + // --- TopicMeta (owner fencing) --- + public static final String OWNER_ID_SHOULD_NOT_BE_EMPTY = + "Subscription topic owner id should not be empty"; + public static final String OWNER_EPOCH_SHOULD_NOT_BE_NEGATIVE = + "Subscription topic owner epoch should not be negative"; + public static final String OWNER_EPOCH_SHOULD_INCREASE_MONOTONICALLY = + "Subscription topic owner epoch should increase monotonically, current epoch is %s, incoming" + + " epoch is %s"; + public static final String OWNER_LEASE_DURATION_SHOULD_BE_POSITIVE = + "Subscription topic owner lease duration should be positive, topic: %s," + + " owner-lease-duration-ms: %s"; + public static final String OWNER_EPOCH_SHOULD_NEVER_BE_REUSED = + "Subscription topic owner epoch should never be reused, topic: %s, max granted owner-epoch:" + + " %s, incoming owner-id: %s, incoming owner-epoch: %s"; + public static final String OWNER_SHOULD_NOT_BE_CLEARED_BY_STALE_META = + "Subscription topic owner should not be cleared by stale topic meta, topic: %s, current" + + " owner-id: %s, current owner-epoch: %s"; + public static final String OWNER_EPOCH_SHOULD_NOT_ROLL_BACK = + "Subscription topic owner epoch should not roll back, topic: %s, current owner-id: %s, current" + + " owner-epoch: %s, incoming owner-id: %s, incoming owner-epoch: %s"; + public static final String OWNER_EPOCH_SHOULD_BE_SET_WHEN_OWNER_ID_SET = + "Subscription topic owner epoch should be set when %s is set"; + public static final String OWNER_LEASE_DURATION_SHOULD_BE_POSITIVE_WHEN_SET = + "Subscription topic owner lease duration should be positive when %s is set"; + private SubscriptionMessages() {} } diff --git a/iotdb-client/subscription/src/main/i18n/zh/org/apache/iotdb/rpc/subscription/i18n/SubscriptionMessages.java b/iotdb-client/subscription/src/main/i18n/zh/org/apache/iotdb/rpc/subscription/i18n/SubscriptionMessages.java index ccd186c9ed348..05d0639d15d5d 100644 --- a/iotdb-client/subscription/src/main/i18n/zh/org/apache/iotdb/rpc/subscription/i18n/SubscriptionMessages.java +++ b/iotdb-client/subscription/src/main/i18n/zh/org/apache/iotdb/rpc/subscription/i18n/SubscriptionMessages.java @@ -91,5 +91,25 @@ public final class SubscriptionMessages { public static final String CONSUMER_SUBMIT_ENDPOINTS_SYNCER = "SubscriptionConsumer {} 提交端点同步器"; + // --- TopicMeta (owner fencing) --- + public static final String OWNER_ID_SHOULD_NOT_BE_EMPTY = "订阅 topic 的 owner id 不应为空"; + public static final String OWNER_EPOCH_SHOULD_NOT_BE_NEGATIVE = "订阅 topic 的 owner epoch 不应为负数"; + public static final String OWNER_EPOCH_SHOULD_INCREASE_MONOTONICALLY = + "订阅 topic 的 owner epoch 应单调递增,当前 epoch 为 %s,传入 epoch 为 %s"; + public static final String OWNER_LEASE_DURATION_SHOULD_BE_POSITIVE = + "订阅 topic 的 owner 租约时长应为正数,topic:%s,owner-lease-duration-ms:%s"; + public static final String OWNER_EPOCH_SHOULD_NEVER_BE_REUSED = + "订阅 topic 的 owner epoch 不应被复用,topic:%s,已授予的最大 owner-epoch:%s,传入 owner-id:%s,传入" + + " owner-epoch:%s"; + public static final String OWNER_SHOULD_NOT_BE_CLEARED_BY_STALE_META = + "订阅 topic 的 owner 不应被过期的 topic meta 清除,topic:%s,当前 owner-id:%s,当前 owner-epoch:%s"; + public static final String OWNER_EPOCH_SHOULD_NOT_ROLL_BACK = + "订阅 topic 的 owner epoch 不应回退,topic:%s,当前 owner-id:%s,当前 owner-epoch:%s,传入" + + " owner-id:%s,传入 owner-epoch:%s"; + public static final String OWNER_EPOCH_SHOULD_BE_SET_WHEN_OWNER_ID_SET = + "当设置 %s 时,订阅 topic 的 owner epoch 也应被设置"; + public static final String OWNER_LEASE_DURATION_SHOULD_BE_POSITIVE_WHEN_SET = + "当设置 %s 时,订阅 topic 的 owner 租约时长应为正数"; + private SubscriptionMessages() {} } diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/ConsumerConfig.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/ConsumerConfig.java index 3cb0087d6827e..13f2a9ee3fb03 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/ConsumerConfig.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/ConsumerConfig.java @@ -68,6 +68,16 @@ public String getConsumerGroupId() { return getString(ConsumerConstant.CONSUMER_GROUP_ID_KEY); } + public String getOwnerId() { + return getString(ConsumerConstant.OWNER_ID_KEY); + } + + public Long getOwnerEpoch() { + return hasAttribute(ConsumerConstant.OWNER_EPOCH_KEY) + ? getLong(ConsumerConstant.OWNER_EPOCH_KEY) + : null; + } + public String getUsername() { return getString(ConsumerConstant.USERNAME_KEY); } diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/ConsumerConstant.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/ConsumerConstant.java index 90d2ea7a01fb0..3df95facf367c 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/ConsumerConstant.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/ConsumerConstant.java @@ -40,6 +40,8 @@ public class ConsumerConstant { public static final String CONSUMER_ID_KEY = "consumer-id"; public static final String CONSUMER_GROUP_ID_KEY = "group-id"; + public static final String OWNER_ID_KEY = "owner-id"; + public static final String OWNER_EPOCH_KEY = "owner-epoch"; public static final String HEARTBEAT_INTERVAL_MS_KEY = "heartbeat-interval-ms"; public static final long HEARTBEAT_INTERVAL_MS_DEFAULT_VALUE = 30_000L; diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicConstant.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicConstant.java index af2be263d34a3..a0b391225c293 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicConstant.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicConstant.java @@ -72,6 +72,14 @@ public class TopicConstant { public static final String STRICT_KEY = "strict"; public static final String STRICT_DEFAULT_VALUE = "true"; + public static final String OWNER_ID_KEY = "owner-id"; + public static final String OWNER_EPOCH_KEY = "owner-epoch"; + // Highest owner epoch ever granted for this topic. Retained across clearOwner so that owner + // epoch is globally monotonic and never reused, even after a clear -> re-enable cycle. Stored as + // a topic attribute so it survives serialization automatically (self-describing). + public static final String MAX_OWNER_EPOCH_KEY = "max-owner-epoch"; + public static final String OWNER_LEASE_DURATION_MS_KEY = "owner-lease-duration-ms"; + private TopicConstant() { throw new IllegalStateException(SubscriptionMessages.UTILITY_CLASS); } diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/exception/SubscriptionOwnerFencedException.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/exception/SubscriptionOwnerFencedException.java new file mode 100644 index 0000000000000..f00c3ba1dcb0c --- /dev/null +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/exception/SubscriptionOwnerFencedException.java @@ -0,0 +1,45 @@ +/* + * 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.rpc.subscription.exception; + +import java.util.Objects; + +public class SubscriptionOwnerFencedException extends SubscriptionRuntimeNonCriticalException { + + public SubscriptionOwnerFencedException(final String message) { + super(message); + } + + public SubscriptionOwnerFencedException(final String message, final Throwable cause) { + super(message, cause); + } + + @Override + public boolean equals(final Object obj) { + return obj instanceof SubscriptionOwnerFencedException + && Objects.equals(getMessage(), ((SubscriptionOwnerFencedException) obj).getMessage()) + && Objects.equals(getTimeStamp(), ((SubscriptionOwnerFencedException) obj).getTimeStamp()); + } + + @Override + public int hashCode() { + return super.hashCode(); + } +} diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/AbstractSubscriptionSession.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/AbstractSubscriptionSession.java index 6683a1c5adf2e..4f3f4b32d3709 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/AbstractSubscriptionSession.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/AbstractSubscriptionSession.java @@ -22,6 +22,7 @@ import org.apache.iotdb.isession.SessionDataSet; import org.apache.iotdb.rpc.IoTDBConnectionException; import org.apache.iotdb.rpc.StatementExecutionException; +import org.apache.iotdb.rpc.subscription.config.TopicConstant; import org.apache.iotdb.rpc.subscription.exception.SubscriptionException; import org.apache.iotdb.session.subscription.model.Subscription; import org.apache.iotdb.session.subscription.model.Topic; @@ -103,27 +104,63 @@ private void createTopic( } return; } - final StringBuilder sb = new StringBuilder(); - sb.append('('); - properties.forEach( - (k, v) -> - sb.append('\'') - .append(k) - .append('\'') - .append('=') - .append('\'') - .append(v) - .append('\'') - .append(',')); - sb.deleteCharAt(sb.length() - 1); - sb.append(')'); final String sql = isSetIfNotExistsCondition - ? String.format("CREATE TOPIC IF NOT EXISTS %s WITH %s", topicName, sb) - : String.format("CREATE TOPIC %s WITH %s", topicName, sb); + ? String.format( + "CREATE TOPIC IF NOT EXISTS %s WITH %s", + topicName, buildTopicAttributesClause(properties)) + : String.format( + "CREATE TOPIC %s WITH %s", topicName, buildTopicAttributesClause(properties)); + session.executeNonQueryStatement(sql); + } + + protected void alterTopic(final String topicName, final Properties properties) + throws IoTDBConnectionException, StatementExecutionException { + alterTopic(topicName, properties, false); + } + + private void alterTopic( + final String topicName, final Properties properties, final boolean allowOwnerAttributes) + throws IoTDBConnectionException, StatementExecutionException { + IdentifierUtils.checkAndParseIdentifier(topicName); // ignore the parse result + if (Objects.isNull(properties) || properties.isEmpty()) { + throw new StatementExecutionException("Topic attributes should not be empty in ALTER TOPIC."); + } + if (!allowOwnerAttributes && containsOwnerAttribute(properties)) { + throw new StatementExecutionException( + "Topic owner attributes should be modified by alterTopicOwner only."); + } + final String sql = + String.format("ALTER TOPIC %s WITH %s", topicName, buildTopicAttributesClause(properties)); session.executeNonQueryStatement(sql); } + protected void alterTopicOwner( + final String topicName, final String ownerId, final long ownerEpoch) + throws IoTDBConnectionException, StatementExecutionException { + alterTopicOwner(topicName, ownerId, ownerEpoch, null); + } + + protected void alterTopicOwner( + final String topicName, + final String ownerId, + final long ownerEpoch, + final Long ownerLeaseDurationMs) + throws IoTDBConnectionException, StatementExecutionException { + if (Objects.isNull(ownerId) || ownerId.isEmpty()) { + throw new StatementExecutionException("Topic owner id should not be empty."); + } + + final Properties properties = new Properties(); + properties.put(TopicConstant.OWNER_ID_KEY, ownerId); + properties.put(TopicConstant.OWNER_EPOCH_KEY, String.valueOf(ownerEpoch)); + if (Objects.nonNull(ownerLeaseDurationMs)) { + properties.put( + TopicConstant.OWNER_LEASE_DURATION_MS_KEY, String.valueOf(ownerLeaseDurationMs)); + } + alterTopic(topicName, properties, true); + } + protected void dropTopic(final String topicName) throws IoTDBConnectionException, StatementExecutionException { IdentifierUtils.checkAndParseIdentifier(topicName); // ignore the parse result @@ -231,4 +268,34 @@ private Set convertDataSetToSubscriptions(final SessionDataSet dat } return subscriptions; } + + private static String buildTopicAttributesClause(final Properties properties) { + final StringBuilder builder = new StringBuilder(); + builder.append('('); + properties.forEach( + (key, value) -> + builder + .append('\'') + .append(escapeSqlStringLiteral(String.valueOf(key))) + .append('\'') + .append('=') + .append('\'') + .append(escapeSqlStringLiteral(String.valueOf(value))) + .append('\'') + .append(',')); + builder.deleteCharAt(builder.length() - 1); + builder.append(')'); + return builder.toString(); + } + + private static String escapeSqlStringLiteral(final String value) { + return value.replace("'", "''"); + } + + private static boolean containsOwnerAttribute(final Properties properties) { + return properties.containsKey(TopicConstant.OWNER_ID_KEY) + || properties.containsKey(TopicConstant.OWNER_EPOCH_KEY) + || properties.containsKey(TopicConstant.MAX_OWNER_EPOCH_KEY) + || properties.containsKey(TopicConstant.OWNER_LEASE_DURATION_MS_KEY); + } } diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/ISubscriptionTableSession.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/ISubscriptionTableSession.java index 2dac0f5106b96..a61ecd60ce39c 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/ISubscriptionTableSession.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/ISubscriptionTableSession.java @@ -99,6 +99,49 @@ void createTopic(final String topicName, final Properties properties) void createTopicIfNotExists(final String topicName, final Properties properties) throws IoTDBConnectionException, StatementExecutionException; + /** + * Alters a topic with the specified properties. + * + * @param topicName If the topic name contains single quotes, the passed parameter needs to be + * enclosed in backticks. + * @param properties A {@link Properties} object containing the topic properties to alter. + * @throws IoTDBConnectionException If a connection issue occurs. + * @throws StatementExecutionException If the SQL statement execution fails. + */ + void alterTopic(final String topicName, final Properties properties) + throws IoTDBConnectionException, StatementExecutionException; + + /** + * Transfers a topic owner to the specified owner id and epoch. + * + * @param topicName If the topic name contains single quotes, the passed parameter needs to be + * enclosed in backticks. + * @param ownerId The new topic owner id. + * @param ownerEpoch The new monotonically increasing topic owner epoch. + * @throws IoTDBConnectionException If a connection issue occurs. + * @throws StatementExecutionException If the SQL statement execution fails. + */ + void alterTopicOwner(final String topicName, final String ownerId, final long ownerEpoch) + throws IoTDBConnectionException, StatementExecutionException; + + /** + * Transfers a topic owner to the specified owner id, epoch, and lease duration. + * + * @param topicName If the topic name contains single quotes, the passed parameter needs to be + * enclosed in backticks. + * @param ownerId The new topic owner id. + * @param ownerEpoch The new monotonically increasing topic owner epoch. + * @param ownerLeaseDurationMs The owner lease duration in milliseconds. + * @throws IoTDBConnectionException If a connection issue occurs. + * @throws StatementExecutionException If the SQL statement execution fails. + */ + void alterTopicOwner( + final String topicName, + final String ownerId, + final long ownerEpoch, + final Long ownerLeaseDurationMs) + throws IoTDBConnectionException, StatementExecutionException; + /** * Drops the specified topic. * diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/ISubscriptionTreeSession.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/ISubscriptionTreeSession.java index 799314769e8eb..eb443575857ca 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/ISubscriptionTreeSession.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/ISubscriptionTreeSession.java @@ -99,6 +99,49 @@ void createTopic(final String topicName, final Properties properties) void createTopicIfNotExists(final String topicName, final Properties properties) throws IoTDBConnectionException, StatementExecutionException; + /** + * Alters a topic with the specified properties. + * + * @param topicName If the topic name contains single quotes, the passed parameter needs to be + * enclosed in backticks. + * @param properties A {@link Properties} object containing the topic properties to alter. + * @throws IoTDBConnectionException If a connection issue occurs. + * @throws StatementExecutionException If the SQL statement execution fails. + */ + void alterTopic(final String topicName, final Properties properties) + throws IoTDBConnectionException, StatementExecutionException; + + /** + * Transfers a topic owner to the specified owner id and epoch. + * + * @param topicName If the topic name contains single quotes, the passed parameter needs to be + * enclosed in backticks. + * @param ownerId The new topic owner id. + * @param ownerEpoch The new monotonically increasing topic owner epoch. + * @throws IoTDBConnectionException If a connection issue occurs. + * @throws StatementExecutionException If the SQL statement execution fails. + */ + void alterTopicOwner(final String topicName, final String ownerId, final long ownerEpoch) + throws IoTDBConnectionException, StatementExecutionException; + + /** + * Transfers a topic owner to the specified owner id, epoch, and lease duration. + * + * @param topicName If the topic name contains single quotes, the passed parameter needs to be + * enclosed in backticks. + * @param ownerId The new topic owner id. + * @param ownerEpoch The new monotonically increasing topic owner epoch. + * @param ownerLeaseDurationMs The owner lease duration in milliseconds. + * @throws IoTDBConnectionException If a connection issue occurs. + * @throws StatementExecutionException If the SQL statement execution fails. + */ + void alterTopicOwner( + final String topicName, + final String ownerId, + final long ownerEpoch, + final Long ownerLeaseDurationMs) + throws IoTDBConnectionException, StatementExecutionException; + /** * Drops the specified topic. * diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/SubscriptionTableSession.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/SubscriptionTableSession.java index bf21a43b26f81..123deea2fa45d 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/SubscriptionTableSession.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/SubscriptionTableSession.java @@ -74,6 +74,28 @@ public void createTopicIfNotExists(final String topicName, final Properties prop super.createTopicIfNotExists(topicName, properties); } + @Override + public void alterTopic(final String topicName, final Properties properties) + throws IoTDBConnectionException, StatementExecutionException { + super.alterTopic(topicName, properties); + } + + @Override + public void alterTopicOwner(final String topicName, final String ownerId, final long ownerEpoch) + throws IoTDBConnectionException, StatementExecutionException { + super.alterTopicOwner(topicName, ownerId, ownerEpoch); + } + + @Override + public void alterTopicOwner( + final String topicName, + final String ownerId, + final long ownerEpoch, + final Long ownerLeaseDurationMs) + throws IoTDBConnectionException, StatementExecutionException { + super.alterTopicOwner(topicName, ownerId, ownerEpoch, ownerLeaseDurationMs); + } + @Override public void dropTopic(final String topicName) throws IoTDBConnectionException, StatementExecutionException { diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/SubscriptionTreeSession.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/SubscriptionTreeSession.java index 900a243434d1c..f429bf455f31f 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/SubscriptionTreeSession.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/SubscriptionTreeSession.java @@ -97,6 +97,28 @@ public void createTopicIfNotExists(final String topicName, final Properties prop super.createTopicIfNotExists(topicName, properties); } + @Override + public void alterTopic(final String topicName, final Properties properties) + throws IoTDBConnectionException, StatementExecutionException { + super.alterTopic(topicName, properties); + } + + @Override + public void alterTopicOwner(final String topicName, final String ownerId, final long ownerEpoch) + throws IoTDBConnectionException, StatementExecutionException { + super.alterTopicOwner(topicName, ownerId, ownerEpoch); + } + + @Override + public void alterTopicOwner( + final String topicName, + final String ownerId, + final long ownerEpoch, + final Long ownerLeaseDurationMs) + throws IoTDBConnectionException, StatementExecutionException { + super.alterTopicOwner(topicName, ownerId, ownerEpoch, ownerLeaseDurationMs); + } + @Override public void dropTopic(final String topicName) throws IoTDBConnectionException, StatementExecutionException { diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/base/AbstractSubscriptionConsumer.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/base/AbstractSubscriptionConsumer.java index b5e187bc8767d..9ff52e4386fc2 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/base/AbstractSubscriptionConsumer.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/base/AbstractSubscriptionConsumer.java @@ -25,6 +25,7 @@ import org.apache.iotdb.rpc.subscription.config.TopicConfig; import org.apache.iotdb.rpc.subscription.exception.SubscriptionConnectionException; import org.apache.iotdb.rpc.subscription.exception.SubscriptionException; +import org.apache.iotdb.rpc.subscription.exception.SubscriptionOwnerFencedException; import org.apache.iotdb.rpc.subscription.exception.SubscriptionPipeTimeoutException; import org.apache.iotdb.rpc.subscription.exception.SubscriptionPollTimeoutException; import org.apache.iotdb.rpc.subscription.exception.SubscriptionRuntimeCriticalException; @@ -113,6 +114,8 @@ abstract class AbstractSubscriptionConsumer implements AutoCloseable { protected String consumerId; protected String consumerGroupId; + protected String ownerId; + protected Long ownerEpoch; private final long heartbeatIntervalMs; private final long endpointsSyncIntervalMs; @@ -182,6 +185,14 @@ public String getConsumerGroupId() { return consumerGroupId; } + public String getOwnerId() { + return ownerId; + } + + public Long getOwnerEpoch() { + return ownerEpoch; + } + /////////////////////////////// ctor /////////////////////////////// protected AbstractSubscriptionConsumer(final AbstractSubscriptionConsumerBuilder builder) { @@ -211,6 +222,8 @@ protected AbstractSubscriptionConsumer(final AbstractSubscriptionConsumerBuilder this.consumerId = builder.consumerId; this.consumerGroupId = builder.consumerGroupId; + this.ownerId = builder.ownerId; + this.ownerEpoch = builder.ownerEpoch; this.heartbeatIntervalMs = builder.heartbeatIntervalMs; this.endpointsSyncIntervalMs = builder.endpointsSyncIntervalMs; @@ -241,6 +254,8 @@ protected AbstractSubscriptionConsumer( .encryptedPassword((String) properties.get(ConsumerConstant.ENCRYPTED_PASSWORD_KEY)) .consumerId((String) properties.get(ConsumerConstant.CONSUMER_ID_KEY)) .consumerGroupId((String) properties.get(ConsumerConstant.CONSUMER_GROUP_ID_KEY)) + .ownerId((String) properties.get(ConsumerConstant.OWNER_ID_KEY)) + .ownerEpoch((Long) properties.get(ConsumerConstant.OWNER_EPOCH_KEY)) .heartbeatIntervalMs( (Long) properties.getOrDefault( @@ -522,6 +537,8 @@ protected abstract AbstractSubscriptionProvider constructSubscriptionProvider( final String encryptedPassword, final String consumerId, final String consumerGroupId, + final String ownerId, + final Long ownerEpoch, final int thriftMaxFrameSize, final long heartbeatIntervalMs, final int connectionTimeoutInMs); @@ -536,6 +553,8 @@ AbstractSubscriptionProvider constructProviderAndHandshake(final TEndPoint endPo this.encryptedPassword, this.consumerId, this.consumerGroupId, + this.ownerId, + this.ownerEpoch, this.thriftMaxFrameSize, this.heartbeatIntervalMs, this.connectionTimeoutInMs); @@ -1607,6 +1626,9 @@ private void subscribeWithRedirection(final Set topicNames) throws Subsc subscribedTopics = provider.subscribe(topicNames); return; } catch (final Exception e) { + if (e instanceof SubscriptionOwnerFencedException) { + throw (SubscriptionOwnerFencedException) e; + } if (e instanceof SubscriptionPipeTimeoutException) { // degrade exception to log for pipe timeout LOGGER.warn(e.getMessage()); @@ -2048,6 +2070,8 @@ protected Map coreReportMessage() { final Map result = new HashMap<>(); result.put("consumerId", consumerId); result.put("consumerGroupId", consumerGroupId); + result.put("ownerId", ownerId); + result.put("ownerEpoch", String.valueOf(ownerEpoch)); result.put("isClosed", isClosed.toString()); result.put("fileSaveDir", fileSaveDir); result.put( @@ -2062,6 +2086,8 @@ protected Map allReportMessage() { final Map result = new HashMap<>(); result.put("consumerId", consumerId); result.put("consumerGroupId", consumerGroupId); + result.put("ownerId", ownerId); + result.put("ownerEpoch", String.valueOf(ownerEpoch)); result.put("heartbeatIntervalMs", String.valueOf(heartbeatIntervalMs)); result.put("endpointsSyncIntervalMs", String.valueOf(endpointsSyncIntervalMs)); result.put("providers", providers.toString()); diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/base/AbstractSubscriptionConsumerBuilder.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/base/AbstractSubscriptionConsumerBuilder.java index 991d237ed2b89..a0c4b421ed18b 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/base/AbstractSubscriptionConsumerBuilder.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/base/AbstractSubscriptionConsumerBuilder.java @@ -40,6 +40,8 @@ public class AbstractSubscriptionConsumerBuilder { protected String consumerId; protected String consumerGroupId; + protected String ownerId; + protected Long ownerEpoch; protected long heartbeatIntervalMs = ConsumerConstant.HEARTBEAT_INTERVAL_MS_DEFAULT_VALUE; protected long endpointsSyncIntervalMs = @@ -111,6 +113,27 @@ public AbstractSubscriptionConsumerBuilder consumerGroupId( return this; } + public AbstractSubscriptionConsumerBuilder ownerId(@Nullable final String ownerId) { + if (Objects.isNull(ownerId)) { + return this; + } + this.ownerId = ownerId; + return this; + } + + public AbstractSubscriptionConsumerBuilder ownerEpoch(final long ownerEpoch) { + this.ownerEpoch = ownerEpoch; + return this; + } + + public AbstractSubscriptionConsumerBuilder ownerEpoch(@Nullable final Long ownerEpoch) { + if (Objects.isNull(ownerEpoch)) { + return this; + } + this.ownerEpoch = ownerEpoch; + return this; + } + public AbstractSubscriptionConsumerBuilder heartbeatIntervalMs(final long heartbeatIntervalMs) { this.heartbeatIntervalMs = Math.max(heartbeatIntervalMs, ConsumerConstant.HEARTBEAT_INTERVAL_MS_MIN_VALUE); diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/base/AbstractSubscriptionProvider.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/base/AbstractSubscriptionProvider.java index 6187ba0ca2767..4aad7fe3681b0 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/base/AbstractSubscriptionProvider.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/base/AbstractSubscriptionProvider.java @@ -27,6 +27,7 @@ import org.apache.iotdb.rpc.subscription.config.TopicConfig; import org.apache.iotdb.rpc.subscription.exception.SubscriptionConnectionException; import org.apache.iotdb.rpc.subscription.exception.SubscriptionException; +import org.apache.iotdb.rpc.subscription.exception.SubscriptionOwnerFencedException; import org.apache.iotdb.rpc.subscription.exception.SubscriptionPipeTimeoutException; import org.apache.iotdb.rpc.subscription.exception.SubscriptionRuntimeCriticalException; import org.apache.iotdb.rpc.subscription.exception.SubscriptionRuntimeNonCriticalException; @@ -85,6 +86,8 @@ public abstract class AbstractSubscriptionProvider { private String consumerId; private String consumerGroupId; + private final String ownerId; + private final Long ownerEpoch; private final AtomicBoolean isClosed = new AtomicBoolean(true); private final AtomicBoolean isAvailable = new AtomicBoolean(false); @@ -114,6 +117,8 @@ protected AbstractSubscriptionProvider( final String encryptedPassword, final String consumerId, final String consumerGroupId, + final String ownerId, + final Long ownerEpoch, final int thriftMaxFrameSize, final long heartbeatIntervalMs, final int connectionTimeoutInMs) { @@ -130,6 +135,8 @@ protected AbstractSubscriptionProvider( this.endPoint = endPoint; this.consumerId = consumerId; this.consumerGroupId = consumerGroupId; + this.ownerId = ownerId; + this.ownerEpoch = ownerEpoch; this.username = username; this.password = password; this.encryptedPassword = encryptedPassword; @@ -182,6 +189,12 @@ synchronized void handshake() throws SubscriptionException, IoTDBConnectionExcep final Map consumerAttributes = new HashMap<>(); consumerAttributes.put(ConsumerConstant.CONSUMER_GROUP_ID_KEY, consumerGroupId); consumerAttributes.put(ConsumerConstant.CONSUMER_ID_KEY, consumerId); + if (ownerId != null) { + consumerAttributes.put(ConsumerConstant.OWNER_ID_KEY, ownerId); + } + if (ownerEpoch != null) { + consumerAttributes.put(ConsumerConstant.OWNER_EPOCH_KEY, String.valueOf(ownerEpoch)); + } consumerAttributes.put(ConsumerConstant.USERNAME_KEY, username); consumerAttributes.put(ConsumerConstant.PASSWORD_KEY, password); if (encryptedPassword != null) { @@ -593,6 +606,7 @@ private static void verifyPipeSubscribeSuccess(final TSStatus status) case 1906: // SUBSCRIPTION_CLOSE_ERROR case 1907: // SUBSCRIPTION_SUBSCRIBE_ERROR case 1908: // SUBSCRIPTION_UNSUBSCRIBE_ERROR + case 1913: // SUBSCRIPTION_SEEK_ERROR { final String errorMessage = String.format(INTERNAL_ERROR_FORMATTER, status.code, status.message); @@ -602,6 +616,17 @@ private static void verifyPipeSubscribeSuccess(final TSStatus status) case 1911: // SUBSCRIPTION_PIPE_TIMEOUT_ERROR throw new SubscriptionPipeTimeoutException( String.format(SUBSCRIPTION_PIPE_TIMEOUT_FORMATTER, status.code, status.message)); + case 1914: // SUBSCRIPTION_OWNER_FENCED + case 1915: // SUBSCRIPTION_OWNER_REQUIRED + case 1916: // SUBSCRIPTION_OWNER_EPOCH_REQUIRED + case 1917: // SUBSCRIPTION_OWNER_LEASE_EXPIRED + case 1918: // SUBSCRIPTION_OWNER_EPOCH_CONFLICT + { + final String errorMessage = + String.format(INTERNAL_ERROR_FORMATTER, status.code, status.message); + LOGGER.warn(errorMessage); + throw new SubscriptionOwnerFencedException(errorMessage); + } case 1900: // SUBSCRIPTION_VERSION_ERROR case 1901: // SUBSCRIPTION_TYPE_ERROR case 1909: // SUBSCRIPTION_MISSING_CONSUMER diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTableProvider.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTableProvider.java index 253a67475ae6f..cb347a940ee6d 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTableProvider.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTableProvider.java @@ -35,6 +35,8 @@ final class SubscriptionTableProvider extends AbstractSubscriptionProvider { final String encryptedPassword, final String consumerId, final String consumerGroupId, + final String ownerId, + final Long ownerEpoch, final int thriftMaxFrameSize, final long heartbeatIntervalMs, final int connectionTimeoutInMs) { @@ -45,6 +47,8 @@ final class SubscriptionTableProvider extends AbstractSubscriptionProvider { encryptedPassword, consumerId, consumerGroupId, + ownerId, + ownerEpoch, thriftMaxFrameSize, heartbeatIntervalMs, connectionTimeoutInMs); diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTablePullConsumer.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTablePullConsumer.java index f00caf1a464a5..dde3a4a959038 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTablePullConsumer.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTablePullConsumer.java @@ -47,6 +47,8 @@ protected AbstractSubscriptionProvider constructSubscriptionProvider( final String encryptedPassword, final String consumerId, final String consumerGroupId, + final String ownerId, + final Long ownerEpoch, final int thriftMaxFrameSize, final long heartbeatIntervalMs, final int connectionTimeoutInMs) { @@ -57,6 +59,8 @@ protected AbstractSubscriptionProvider constructSubscriptionProvider( encryptedPassword, consumerId, consumerGroupId, + ownerId, + ownerEpoch, thriftMaxFrameSize, heartbeatIntervalMs, connectionTimeoutInMs); diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTablePullConsumerBuilder.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTablePullConsumerBuilder.java index 939228a7f49e9..efc99debb4977 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTablePullConsumerBuilder.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTablePullConsumerBuilder.java @@ -74,6 +74,24 @@ public SubscriptionTablePullConsumerBuilder consumerGroupId(final String consume return this; } + @Override + public SubscriptionTablePullConsumerBuilder ownerId(final String ownerId) { + super.ownerId(ownerId); + return this; + } + + @Override + public SubscriptionTablePullConsumerBuilder ownerEpoch(final long ownerEpoch) { + super.ownerEpoch(ownerEpoch); + return this; + } + + @Override + public SubscriptionTablePullConsumerBuilder ownerEpoch(final Long ownerEpoch) { + super.ownerEpoch(ownerEpoch); + return this; + } + @Override public SubscriptionTablePullConsumerBuilder heartbeatIntervalMs(final long heartbeatIntervalMs) { super.heartbeatIntervalMs(heartbeatIntervalMs); diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTablePushConsumer.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTablePushConsumer.java index e90afc1d8d175..4c85993a933dc 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTablePushConsumer.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTablePushConsumer.java @@ -41,6 +41,8 @@ protected AbstractSubscriptionProvider constructSubscriptionProvider( final String encryptedPassword, final String consumerId, final String consumerGroupId, + final String ownerId, + final Long ownerEpoch, final int thriftMaxFrameSize, final long heartbeatIntervalMs, final int connectionTimeoutInMs) { @@ -51,6 +53,8 @@ protected AbstractSubscriptionProvider constructSubscriptionProvider( encryptedPassword, consumerId, consumerGroupId, + ownerId, + ownerEpoch, thriftMaxFrameSize, heartbeatIntervalMs, connectionTimeoutInMs); diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTablePushConsumerBuilder.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTablePushConsumerBuilder.java index 27bf328fea9e6..143b34056af9e 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTablePushConsumerBuilder.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/table/SubscriptionTablePushConsumerBuilder.java @@ -76,6 +76,24 @@ public SubscriptionTablePushConsumerBuilder consumerGroupId(final String consume return this; } + @Override + public SubscriptionTablePushConsumerBuilder ownerId(final String ownerId) { + super.ownerId(ownerId); + return this; + } + + @Override + public SubscriptionTablePushConsumerBuilder ownerEpoch(final long ownerEpoch) { + super.ownerEpoch(ownerEpoch); + return this; + } + + @Override + public SubscriptionTablePushConsumerBuilder ownerEpoch(final Long ownerEpoch) { + super.ownerEpoch(ownerEpoch); + return this; + } + @Override public SubscriptionTablePushConsumerBuilder heartbeatIntervalMs(final long heartbeatIntervalMs) { super.heartbeatIntervalMs(heartbeatIntervalMs); diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreeProvider.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreeProvider.java index 46bb0917b9ca9..a121112d7a1a7 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreeProvider.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreeProvider.java @@ -35,6 +35,8 @@ final class SubscriptionTreeProvider extends AbstractSubscriptionProvider { final String encryptedPassword, final String consumerId, final String consumerGroupId, + final String ownerId, + final Long ownerEpoch, final int thriftMaxFrameSize, final long heartbeatIntervalMs, final int connectionTimeoutInMs) { @@ -45,6 +47,8 @@ final class SubscriptionTreeProvider extends AbstractSubscriptionProvider { encryptedPassword, consumerId, consumerGroupId, + ownerId, + ownerEpoch, thriftMaxFrameSize, heartbeatIntervalMs, connectionTimeoutInMs); diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreePullConsumer.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreePullConsumer.java index f1aca88b2ff10..551eb4c329804 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreePullConsumer.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreePullConsumer.java @@ -54,6 +54,8 @@ protected AbstractSubscriptionProvider constructSubscriptionProvider( final String encryptedPassword, final String consumerId, final String consumerGroupId, + final String ownerId, + final Long ownerEpoch, final int thriftMaxFrameSize, final long heartbeatIntervalMs, final int connectionTimeoutInMs) { @@ -64,6 +66,8 @@ protected AbstractSubscriptionProvider constructSubscriptionProvider( encryptedPassword, consumerId, consumerGroupId, + ownerId, + ownerEpoch, thriftMaxFrameSize, heartbeatIntervalMs, connectionTimeoutInMs); @@ -87,6 +91,8 @@ private SubscriptionTreePullConsumer(final SubscriptionTreePullConsumer.Builder .encryptedPassword(builder.encryptedPassword) .consumerId(builder.consumerId) .consumerGroupId(builder.consumerGroupId) + .ownerId(builder.ownerId) + .ownerEpoch(builder.ownerEpoch) .heartbeatIntervalMs(builder.heartbeatIntervalMs) .endpointsSyncIntervalMs(builder.endpointsSyncIntervalMs) .fileSaveDir(builder.fileSaveDir) @@ -265,6 +271,8 @@ public static class Builder { private String consumerId; private String consumerGroupId; + private String ownerId; + private Long ownerEpoch; private long heartbeatIntervalMs = ConsumerConstant.HEARTBEAT_INTERVAL_MS_DEFAULT_VALUE; private long endpointsSyncIntervalMs = @@ -326,6 +334,19 @@ public Builder consumerGroupId(@Nullable final String consumerGroupId) { return this; } + public Builder ownerId(@Nullable final String ownerId) { + if (Objects.isNull(ownerId)) { + return this; + } + this.ownerId = ownerId; + return this; + } + + public Builder ownerEpoch(final long ownerEpoch) { + this.ownerEpoch = ownerEpoch; + return this; + } + public Builder heartbeatIntervalMs(final long heartbeatIntervalMs) { this.heartbeatIntervalMs = Math.max(heartbeatIntervalMs, ConsumerConstant.HEARTBEAT_INTERVAL_MS_MIN_VALUE); diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreePullConsumerBuilder.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreePullConsumerBuilder.java index cbceb95d77f90..2d057b7bfbd80 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreePullConsumerBuilder.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreePullConsumerBuilder.java @@ -74,6 +74,24 @@ public SubscriptionTreePullConsumerBuilder consumerGroupId(final String consumer return this; } + @Override + public SubscriptionTreePullConsumerBuilder ownerId(final String ownerId) { + super.ownerId(ownerId); + return this; + } + + @Override + public SubscriptionTreePullConsumerBuilder ownerEpoch(final long ownerEpoch) { + super.ownerEpoch(ownerEpoch); + return this; + } + + @Override + public SubscriptionTreePullConsumerBuilder ownerEpoch(final Long ownerEpoch) { + super.ownerEpoch(ownerEpoch); + return this; + } + @Override public SubscriptionTreePullConsumerBuilder heartbeatIntervalMs(final long heartbeatIntervalMs) { super.heartbeatIntervalMs(heartbeatIntervalMs); diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreePushConsumer.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreePushConsumer.java index 4d8a5ef3e169f..8fbc33d14ce8a 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreePushConsumer.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreePushConsumer.java @@ -51,6 +51,8 @@ protected AbstractSubscriptionProvider constructSubscriptionProvider( final String encryptedPassword, final String consumerId, final String consumerGroupId, + final String ownerId, + final Long ownerEpoch, final int thriftMaxFrameSize, final long heartbeatIntervalMs, final int connectionTimeoutInMs) { @@ -61,6 +63,8 @@ protected AbstractSubscriptionProvider constructSubscriptionProvider( encryptedPassword, consumerId, consumerGroupId, + ownerId, + ownerEpoch, thriftMaxFrameSize, heartbeatIntervalMs, connectionTimeoutInMs); @@ -84,6 +88,8 @@ private SubscriptionTreePushConsumer(final Builder builder) { .encryptedPassword(builder.encryptedPassword) .consumerId(builder.consumerId) .consumerGroupId(builder.consumerGroupId) + .ownerId(builder.ownerId) + .ownerEpoch(builder.ownerEpoch) .heartbeatIntervalMs(builder.heartbeatIntervalMs) .endpointsSyncIntervalMs(builder.endpointsSyncIntervalMs) .fileSaveDir(builder.fileSaveDir) @@ -192,6 +198,8 @@ public static class Builder { private String consumerId; private String consumerGroupId; + private String ownerId; + private Long ownerEpoch; private long heartbeatIntervalMs = ConsumerConstant.HEARTBEAT_INTERVAL_MS_DEFAULT_VALUE; private long endpointsSyncIntervalMs = @@ -256,6 +264,19 @@ public Builder consumerGroupId(@Nullable final String consumerGroupId) { return this; } + public Builder ownerId(@Nullable final String ownerId) { + if (Objects.isNull(ownerId)) { + return this; + } + this.ownerId = ownerId; + return this; + } + + public Builder ownerEpoch(final long ownerEpoch) { + this.ownerEpoch = ownerEpoch; + return this; + } + public Builder heartbeatIntervalMs(final long heartbeatIntervalMs) { this.heartbeatIntervalMs = Math.max(heartbeatIntervalMs, ConsumerConstant.HEARTBEAT_INTERVAL_MS_MIN_VALUE); diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreePushConsumerBuilder.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreePushConsumerBuilder.java index 86594433e77e0..9067f3e7ae564 100644 --- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreePushConsumerBuilder.java +++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/session/subscription/consumer/tree/SubscriptionTreePushConsumerBuilder.java @@ -76,6 +76,24 @@ public SubscriptionTreePushConsumerBuilder consumerGroupId(final String consumer return this; } + @Override + public SubscriptionTreePushConsumerBuilder ownerId(final String ownerId) { + super.ownerId(ownerId); + return this; + } + + @Override + public SubscriptionTreePushConsumerBuilder ownerEpoch(final long ownerEpoch) { + super.ownerEpoch(ownerEpoch); + return this; + } + + @Override + public SubscriptionTreePushConsumerBuilder ownerEpoch(final Long ownerEpoch) { + super.ownerEpoch(ownerEpoch); + return this; + } + @Override public SubscriptionTreePushConsumerBuilder heartbeatIntervalMs(final long heartbeatIntervalMs) { super.heartbeatIntervalMs(heartbeatIntervalMs); diff --git a/iotdb-core/antlr/src/main/antlr4/org/apache/iotdb/db/qp/sql/IoTDBSqlParser.g4 b/iotdb-core/antlr/src/main/antlr4/org/apache/iotdb/db/qp/sql/IoTDBSqlParser.g4 index 3e94667a2faa5..9062d610b8be2 100644 --- a/iotdb-core/antlr/src/main/antlr4/org/apache/iotdb/db/qp/sql/IoTDBSqlParser.g4 +++ b/iotdb-core/antlr/src/main/antlr4/org/apache/iotdb/db/qp/sql/IoTDBSqlParser.g4 @@ -59,7 +59,7 @@ ddlStatement // Pipe Plugin | createPipePlugin | dropPipePlugin | showPipePlugins // Subscription - | createTopic | dropTopic | showTopics | showSubscriptions | dropSubscription + | createTopic | alterTopic | dropTopic | showTopics | showSubscriptions | dropSubscription // CQ | createContinuousQuery | dropContinuousQuery | showContinuousQueries // Cluster @@ -720,6 +720,10 @@ createTopic : CREATE TOPIC (IF NOT EXISTS)? topicName=identifier topicAttributesClause? ; +alterTopic + : ALTER TOPIC topicName=identifier topicAttributesClause + ; + topicAttributesClause : WITH LR_BRACKET topicAttributeClause (COMMA topicAttributeClause)* RR_BRACKET ; diff --git a/iotdb-core/confignode/src/main/i18n/en/org/apache/iotdb/confignode/i18n/ManagerMessages.java b/iotdb-core/confignode/src/main/i18n/en/org/apache/iotdb/confignode/i18n/ManagerMessages.java index 5dbfe15157668..0fa40c2aad368 100644 --- a/iotdb-core/confignode/src/main/i18n/en/org/apache/iotdb/confignode/i18n/ManagerMessages.java +++ b/iotdb-core/confignode/src/main/i18n/en/org/apache/iotdb/confignode/i18n/ManagerMessages.java @@ -134,6 +134,12 @@ public final class ManagerMessages { "Failed to create subtask for pipe %s, creation time %d"; public static final String FAILED_TO_CREATE_TOPIC_WITH_ATTRIBUTES_RESULT_STATUS = "Failed to create topic {} with attributes {}. Result status: {}."; + public static final String FAILED_TO_ALTER_TOPIC_THE_TOPIC_IS_NOT_EXISTED = + "Failed to alter topic %s, the topic is not existed"; + public static final String FAILED_TO_ALTER_TOPIC_WITH_ATTRIBUTES_RESULT_STATUS = + "Failed to alter topic {} with attributes {}. Result status: {}."; + public static final String OWNER_LEASE_DURATION_BELOW_MIN = + "Failed to create or alter topic, owner-lease-duration-ms %s is below the minimum allowed %s ms."; public static final String FAILED_TO_DEEP_COPY_PIPEMETA = "failed to deep copy pipeMeta"; public static final String FAILED_TO_DEREGISTER_PIPE_CONFIG_REGION_CONNECTOR = "Failed to deregister pipe config region connector metrics, PipeConfigNodeSubtask({}) does not exist"; diff --git a/iotdb-core/confignode/src/main/i18n/zh/org/apache/iotdb/confignode/i18n/ManagerMessages.java b/iotdb-core/confignode/src/main/i18n/zh/org/apache/iotdb/confignode/i18n/ManagerMessages.java index e7321e5376c7c..829925f3153a6 100644 --- a/iotdb-core/confignode/src/main/i18n/zh/org/apache/iotdb/confignode/i18n/ManagerMessages.java +++ b/iotdb-core/confignode/src/main/i18n/zh/org/apache/iotdb/confignode/i18n/ManagerMessages.java @@ -133,6 +133,12 @@ public final class ManagerMessages { "Failed to create subtask for pipe %s, creation time %d"; public static final String FAILED_TO_CREATE_TOPIC_WITH_ATTRIBUTES_RESULT_STATUS = "Failed to create topic {} with attributes {}. Result status: {}."; + public static final String FAILED_TO_ALTER_TOPIC_THE_TOPIC_IS_NOT_EXISTED = + "修改 topic %s 失败,该 topic 不存在"; + public static final String FAILED_TO_ALTER_TOPIC_WITH_ATTRIBUTES_RESULT_STATUS = + "修改 topic {} 失败,属性:{}。结果状态:{}。"; + public static final String OWNER_LEASE_DURATION_BELOW_MIN = + "创建或修改 topic 失败,owner-lease-duration-ms %s 小于允许的最小值 %s ms。"; public static final String FAILED_TO_DEEP_COPY_PIPEMETA = "深拷贝 pipeMeta 失败"; public static final String FAILED_TO_DEREGISTER_PIPE_CONFIG_REGION_CONNECTOR = "Failed to deregister pipe config region connector metrics, PipeConfigNodeSubtask({}) does not exist"; diff --git a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/client/async/CnToDnAsyncRequestType.java b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/client/async/CnToDnAsyncRequestType.java index b3c34dd72d2f4..d4db2e5c5a64b 100644 --- a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/client/async/CnToDnAsyncRequestType.java +++ b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/client/async/CnToDnAsyncRequestType.java @@ -82,6 +82,7 @@ public enum CnToDnAsyncRequestType { CONSUMER_GROUP_PUSH_SINGLE_META, PULL_COMMIT_PROGRESS, SUBSCRIPTION_PUSH_RUNTIME, + SUBSCRIPTION_PUSH_OWNER_LEASE, // TEMPLATE UPDATE_TEMPLATE, diff --git a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/client/async/CnToDnInternalServiceAsyncRequestManager.java b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/client/async/CnToDnInternalServiceAsyncRequestManager.java index 54ef5e39088a0..4aec391f7af2c 100644 --- a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/client/async/CnToDnInternalServiceAsyncRequestManager.java +++ b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/client/async/CnToDnInternalServiceAsyncRequestManager.java @@ -96,6 +96,7 @@ import org.apache.iotdb.mpp.rpc.thrift.TPushSingleTopicMetaReq; import org.apache.iotdb.mpp.rpc.thrift.TPushSubscriptionRuntimeReq; import org.apache.iotdb.mpp.rpc.thrift.TPushTopicMetaReq; +import org.apache.iotdb.mpp.rpc.thrift.TPushTopicOwnerLeaseReq; import org.apache.iotdb.mpp.rpc.thrift.TRegionLeaderChangeReq; import org.apache.iotdb.mpp.rpc.thrift.TRegionRouteReq; import org.apache.iotdb.mpp.rpc.thrift.TResetPeerListReq; @@ -244,6 +245,11 @@ protected void initActionMapBuilder() { (req, client, handler) -> client.pushSubscriptionRuntime( (TPushSubscriptionRuntimeReq) req, (DataNodeTSStatusRPCHandler) handler)); + actionMapBuilder.put( + CnToDnAsyncRequestType.SUBSCRIPTION_PUSH_OWNER_LEASE, + (req, client, handler) -> + client.pushTopicOwnerLease( + (TPushTopicOwnerLeaseReq) req, (DataNodeTSStatusRPCHandler) handler)); actionMapBuilder.put( CnToDnAsyncRequestType.PIPE_HEARTBEAT, (req, client, handler) -> diff --git a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/ConfigManager.java b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/ConfigManager.java index 64ee0c54791cc..305905bfbbaa3 100644 --- a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/ConfigManager.java +++ b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/ConfigManager.java @@ -149,6 +149,7 @@ import org.apache.iotdb.confignode.rpc.thrift.TAlterPipeReq; import org.apache.iotdb.confignode.rpc.thrift.TAlterSchemaTemplateReq; import org.apache.iotdb.confignode.rpc.thrift.TAlterTimeSeriesReq; +import org.apache.iotdb.confignode.rpc.thrift.TAlterTopicReq; import org.apache.iotdb.confignode.rpc.thrift.TAuthizedPatternTreeResp; import org.apache.iotdb.confignode.rpc.thrift.TCloseConsumerReq; import org.apache.iotdb.confignode.rpc.thrift.TClusterParameters; @@ -2468,6 +2469,14 @@ public TSStatus createTopic(TCreateTopicReq req) { : status; } + @Override + public TSStatus alterTopic(TAlterTopicReq req) { + TSStatus status = confirmLeader(); + return status.getCode() == TSStatusCode.SUCCESS_STATUS.getStatusCode() + ? subscriptionManager.getSubscriptionCoordinator().alterTopic(req) + : status; + } + @Override public TSStatus dropTopic(TDropTopicReq req) { TSStatus status = confirmLeader(); diff --git a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/IManager.java b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/IManager.java index 4dce39a9e98f0..fa708dbaeca83 100644 --- a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/IManager.java +++ b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/IManager.java @@ -70,6 +70,7 @@ import org.apache.iotdb.confignode.rpc.thrift.TAlterPipeReq; import org.apache.iotdb.confignode.rpc.thrift.TAlterSchemaTemplateReq; import org.apache.iotdb.confignode.rpc.thrift.TAlterTimeSeriesReq; +import org.apache.iotdb.confignode.rpc.thrift.TAlterTopicReq; import org.apache.iotdb.confignode.rpc.thrift.TCloseConsumerReq; import org.apache.iotdb.confignode.rpc.thrift.TConfigNodeRegisterReq; import org.apache.iotdb.confignode.rpc.thrift.TConfigNodeRegisterResp; @@ -803,6 +804,9 @@ TPermissionInfoResp login( /** Create Topic. */ TSStatus createTopic(TCreateTopicReq topic); + /** Alter Topic. */ + TSStatus alterTopic(TAlterTopicReq req); + /** Drop Topic. */ TSStatus dropTopic(TDropTopicReq req); diff --git a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/ProcedureManager.java b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/ProcedureManager.java index a974f9e1d7cb7..9a9387fbc8b32 100644 --- a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/ProcedureManager.java +++ b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/ProcedureManager.java @@ -42,6 +42,7 @@ import org.apache.iotdb.commons.schema.template.Template; import org.apache.iotdb.commons.schema.view.viewExpression.ViewExpression; import org.apache.iotdb.commons.service.metric.MetricService; +import org.apache.iotdb.commons.subscription.meta.topic.TopicMeta; import org.apache.iotdb.commons.trigger.TriggerInformation; import org.apache.iotdb.commons.utils.StatusUtils; import org.apache.iotdb.commons.utils.TestOnly; @@ -121,6 +122,7 @@ import org.apache.iotdb.confignode.procedure.impl.subscription.runtime.SubscriptionHandleLeaderChangeProcedure; import org.apache.iotdb.confignode.procedure.impl.subscription.subscription.CreateSubscriptionProcedure; import org.apache.iotdb.confignode.procedure.impl.subscription.subscription.DropSubscriptionProcedure; +import org.apache.iotdb.confignode.procedure.impl.subscription.topic.AlterTopicProcedure; import org.apache.iotdb.confignode.procedure.impl.subscription.topic.CreateTopicProcedure; import org.apache.iotdb.confignode.procedure.impl.subscription.topic.DropTopicProcedure; import org.apache.iotdb.confignode.procedure.impl.subscription.topic.runtime.TopicMetaSyncProcedure; @@ -138,6 +140,7 @@ import org.apache.iotdb.confignode.rpc.thrift.TAlterLogicalViewReq; import org.apache.iotdb.confignode.rpc.thrift.TAlterOrDropTableReq; import org.apache.iotdb.confignode.rpc.thrift.TAlterPipeReq; +import org.apache.iotdb.confignode.rpc.thrift.TAlterTopicReq; import org.apache.iotdb.confignode.rpc.thrift.TCloseConsumerReq; import org.apache.iotdb.confignode.rpc.thrift.TConfigNodeRegisterReq; import org.apache.iotdb.confignode.rpc.thrift.TCreateCQReq; @@ -1750,6 +1753,51 @@ public TSStatus createTopic(TCreateTopicReq req) { } } + public TSStatus alterTopic(TAlterTopicReq req) { + boolean isOwnerLeaseRenewalBlocked = false; + try { + isOwnerLeaseRenewalBlocked = + configManager + .getSubscriptionManager() + .getSubscriptionCoordinator() + .blockOwnerLeaseRenewalIfOwnerTransfer(req); + // Owner transfers wait for the previous owner's lease to drain (lease duration + one + // heartbeat interval, measured on the ConfigNode clock) inside the call below before the + // updated meta is built; epoch fencing on DataNodes guarantees correctness in the meantime. + final TopicMeta updatedTopicMeta = + configManager + .getSubscriptionManager() + .getSubscriptionCoordinator() + .buildAlteredTopicMetaAfterOwnerLeaseExpired(req); + if (updatedTopicMeta == null) { + return new TSStatus(TSStatusCode.ALTER_TOPIC_ERROR.getStatusCode()) + .setMessage( + String.format( + ManagerMessages.FAILED_TO_ALTER_TOPIC_THE_TOPIC_IS_NOT_EXISTED, + req.getTopicName())); + } + + AlterTopicProcedure procedure = new AlterTopicProcedure(updatedTopicMeta); + executor.submitProcedure(procedure); + TSStatus status = waitingProcedureFinished(procedure); + if (status.getCode() == TSStatusCode.SUCCESS_STATUS.getStatusCode()) { + return status; + } + return new TSStatus(TSStatusCode.ALTER_TOPIC_ERROR.getStatusCode()) + .setMessage(wrapTimeoutMessageForPipeProcedure(status.getMessage())); + } catch (Exception e) { + return new TSStatus(TSStatusCode.ALTER_TOPIC_ERROR.getStatusCode()) + .setMessage(e.getMessage()); + } finally { + if (isOwnerLeaseRenewalBlocked) { + configManager + .getSubscriptionManager() + .getSubscriptionCoordinator() + .unblockOwnerLeaseRenewal(req.getTopicName()); + } + } + } + public TSStatus dropTopic(String topicName) { try { DropTopicProcedure procedure = new DropTopicProcedure(topicName); diff --git a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/subscription/SubscriptionCoordinator.java b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/subscription/SubscriptionCoordinator.java index 83031570fa61e..6fbda89257b46 100644 --- a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/subscription/SubscriptionCoordinator.java +++ b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/subscription/SubscriptionCoordinator.java @@ -19,7 +19,12 @@ package org.apache.iotdb.confignode.manager.subscription; +import org.apache.iotdb.common.rpc.thrift.TDataNodeLocation; import org.apache.iotdb.common.rpc.thrift.TSStatus; +import org.apache.iotdb.commons.subscription.meta.topic.TopicMeta; +import org.apache.iotdb.confignode.client.async.CnToDnAsyncRequestType; +import org.apache.iotdb.confignode.client.async.CnToDnInternalServiceAsyncRequestManager; +import org.apache.iotdb.confignode.client.async.handlers.DataNodeAsyncRequestContext; import org.apache.iotdb.confignode.consensus.request.read.subscription.ShowSubscriptionPlan; import org.apache.iotdb.confignode.consensus.request.read.subscription.ShowTopicPlan; import org.apache.iotdb.confignode.consensus.response.subscription.SubscriptionTableResp; @@ -28,6 +33,7 @@ import org.apache.iotdb.confignode.manager.ConfigManager; import org.apache.iotdb.confignode.manager.pipe.coordinator.task.PipeTaskCoordinatorLock; import org.apache.iotdb.confignode.persistence.subscription.SubscriptionInfo; +import org.apache.iotdb.confignode.rpc.thrift.TAlterTopicReq; import org.apache.iotdb.confignode.rpc.thrift.TCloseConsumerReq; import org.apache.iotdb.confignode.rpc.thrift.TCreateConsumerReq; import org.apache.iotdb.confignode.rpc.thrift.TCreateTopicReq; @@ -41,6 +47,8 @@ import org.apache.iotdb.confignode.rpc.thrift.TShowTopicResp; import org.apache.iotdb.confignode.rpc.thrift.TSubscribeReq; import org.apache.iotdb.confignode.rpc.thrift.TUnsubscribeReq; +import org.apache.iotdb.mpp.rpc.thrift.TPushTopicOwnerLeaseReq; +import org.apache.iotdb.mpp.rpc.thrift.TTopicOwnerLeaseEntry; import org.apache.iotdb.rpc.RpcUtils; import org.apache.iotdb.rpc.TSStatusCode; @@ -49,7 +57,13 @@ import org.slf4j.LoggerFactory; import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.concurrent.atomic.AtomicReference; public class SubscriptionCoordinator { @@ -65,12 +79,18 @@ public class SubscriptionCoordinator { private AtomicReference subscriptionInfoHolder; private final SubscriptionMetaSyncer subscriptionMetaSyncer; + private final SubscriptionOwnerLeaseSyncer subscriptionOwnerLeaseSyncer; + // topicName -> blockSinceMs (ConfigNode local clock when owner-lease renewal was stopped for an + // in-flight owner transfer). Used to skip renewal and to bound the admission wait. + private final Map blockedOwnerLeaseRenewalTopics = + Collections.synchronizedMap(new HashMap<>()); public SubscriptionCoordinator(ConfigManager configManager, SubscriptionInfo subscriptionInfo) { this.configManager = configManager; this.subscriptionInfo = subscriptionInfo; this.coordinatorLock = new PipeTaskCoordinatorLock(); this.subscriptionMetaSyncer = new SubscriptionMetaSyncer(configManager); + this.subscriptionOwnerLeaseSyncer = new SubscriptionOwnerLeaseSyncer(configManager); } public SubscriptionInfo getSubscriptionInfo() { @@ -118,10 +138,12 @@ public boolean isLocked() { public void startSubscriptionMetaSync() { subscriptionMetaSyncer.start(); + subscriptionOwnerLeaseSyncer.start(); } public void stopSubscriptionMetaSync() { subscriptionMetaSyncer.stop(); + subscriptionOwnerLeaseSyncer.stop(); } /** @@ -149,6 +171,107 @@ public TSStatus createTopic(TCreateTopicReq req) { return status; } + public TSStatus alterTopic(TAlterTopicReq req) { + final TSStatus status = configManager.getProcedureManager().alterTopic(req); + if (status.getCode() != TSStatusCode.SUCCESS_STATUS.getStatusCode()) { + LOGGER.warn( + ManagerMessages.FAILED_TO_ALTER_TOPIC_WITH_ATTRIBUTES_RESULT_STATUS, + req.getTopicName(), + req.getTopicAttributes(), + status); + } + return status; + } + + public boolean blockOwnerLeaseRenewalIfOwnerTransfer(TAlterTopicReq req) { + final TopicMeta currentTopicMeta = subscriptionInfo.deepCopyTopicMeta(req.getTopicName()); + final TopicMeta updatedTopicMeta = buildAlteredTopicMeta(req); + if (Objects.isNull(currentTopicMeta) + || Objects.isNull(updatedTopicMeta) + || Objects.equals(currentTopicMeta.getOwnerId(), updatedTopicMeta.getOwnerId())) { + return false; + } + + blockedOwnerLeaseRenewalTopics.put(req.getTopicName(), System.currentTimeMillis()); + return true; + } + + public void unblockOwnerLeaseRenewal(String topicName) { + blockedOwnerLeaseRenewalTopics.remove(topicName); + } + + /** + * Block until it is safe to install the new owner: every DataNode must have let the old owner's + * lease expire. Since renewal was stopped at {@code blockSince}, a DataNode's lease can expire as + * late as {@code blockSince + H + L} (H covers propagation of the last renewal sent just before + * the block; L is the lease duration). We wait that long measured purely on this ConfigNode's own + * clock from {@code blockSince} — no cross-node timestamp comparison, so clock skew cannot shrink + * the window. This replaces any absolute-expire-timestamp comparison. + */ + public TopicMeta buildAlteredTopicMetaAfterOwnerLeaseExpired(TAlterTopicReq req) + throws InterruptedException { + final TopicMeta currentTopicMeta = subscriptionInfo.deepCopyTopicMeta(req.getTopicName()); + final TopicMeta updatedTopicMeta = buildAlteredTopicMeta(req); + if (Objects.isNull(currentTopicMeta) + || Objects.isNull(updatedTopicMeta) + || Objects.equals(currentTopicMeta.getOwnerId(), updatedTopicMeta.getOwnerId())) { + // Not an owner change: nothing to drain. + return updatedTopicMeta; + } + + final Long leaseDurationMs = currentTopicMeta.getOwnerLeaseDurationMs(); + final Long blockSinceMs = blockedOwnerLeaseRenewalTopics.get(req.getTopicName()); + if (Objects.isNull(leaseDurationMs) || Objects.isNull(blockSinceMs)) { + // No lease configured (no drain to wait for) or renewal not blocked: epoch fencing applies on + // reachable DataNodes; nothing further to wait on here. + return updatedTopicMeta; + } + + final long drainDeadlineMs = + blockSinceMs + leaseDurationMs + SubscriptionOwnerLeaseSyncer.getHeartbeatIntervalMs(); + long remainingMs; + while ((remainingMs = drainDeadlineMs - System.currentTimeMillis()) > 0) { + Thread.sleep(Math.min(remainingMs, 1000L)); + } + return updatedTopicMeta; + } + + public TopicMeta buildAlteredTopicMeta(TAlterTopicReq req) { + return subscriptionInfo.deepCopyTopicMetaWithUpdatedAttributes( + req.getTopicName(), req.getTopicAttributes()); + } + + /** + * Build and push owner-lease renewals to all DataNodes via the dedicated subscription owner + * heartbeat (independent from the node heartbeat). Topics undergoing an owner transfer are + * skipped so their lease drains on the DataNodes. Best-effort: a DataNode that misses pushes will + * let its local lease expire and fence the owner (fail-closed). + */ + public void pushTopicOwnerLeasesToDataNodes() { + final Set blockedTopicNames; + synchronized (blockedOwnerLeaseRenewalTopics) { + blockedTopicNames = new HashSet<>(blockedOwnerLeaseRenewalTopics.keySet()); + } + + final List ownerLeases = + subscriptionInfo.collectTopicOwnerLeaseEntries(blockedTopicNames); + if (ownerLeases.isEmpty()) { + return; + } + + final Map dataNodeLocationMap = + configManager.getNodeManager().getRegisteredDataNodeLocations(); + if (dataNodeLocationMap.isEmpty()) { + return; + } + + final TPushTopicOwnerLeaseReq request = new TPushTopicOwnerLeaseReq(ownerLeases); + final DataNodeAsyncRequestContext clientHandler = + new DataNodeAsyncRequestContext<>( + CnToDnAsyncRequestType.SUBSCRIPTION_PUSH_OWNER_LEASE, request, dataNodeLocationMap); + CnToDnInternalServiceAsyncRequestManager.getInstance().sendAsyncRequestWithRetry(clientHandler); + } + public TSStatus dropTopic(TDropTopicReq req) { final String topicName = req.getTopicName(); final boolean isSetIfExistsCondition = diff --git a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/subscription/SubscriptionOwnerLeaseSyncer.java b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/subscription/SubscriptionOwnerLeaseSyncer.java new file mode 100644 index 0000000000000..bbc22a35c8d84 --- /dev/null +++ b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/subscription/SubscriptionOwnerLeaseSyncer.java @@ -0,0 +1,97 @@ +/* + * 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.confignode.manager.subscription; + +import org.apache.iotdb.commons.concurrent.IoTDBThreadPoolFactory; +import org.apache.iotdb.commons.concurrent.threadpool.ScheduledExecutorUtil; +import org.apache.iotdb.confignode.manager.ConfigManager; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Dedicated, fine-grained subscription owner-lease heartbeat, independent from the node heartbeat. + * + *

Each tick pushes relative-duration owner leases to all DataNodes (see {@link + * SubscriptionCoordinator#pushTopicOwnerLeasesToDataNodes()}). A DataNode that stops receiving + * pushes (e.g. partitioned from this ConfigNode) lets its local lease expire and fences the owner + * (fail-closed). The interval must stay well below the configured lease duration so transient + * misses do not spuriously fence a healthy owner. + */ +public class SubscriptionOwnerLeaseSyncer { + + private static final Logger LOGGER = LoggerFactory.getLogger(SubscriptionOwnerLeaseSyncer.class); + + // Note: candidate for promotion to SubscriptionConfig; invariant must hold: lease-duration >> + // interval * tolerated-misses. + private static final long OWNER_LEASE_HEARTBEAT_INTERVAL_SECONDS = 5; + + /** The owner-lease heartbeat interval {@code H}, used by the transfer admission-wait bound. */ + public static long getHeartbeatIntervalMs() { + return OWNER_LEASE_HEARTBEAT_INTERVAL_SECONDS * 1000L; + } + + private static final ScheduledExecutorService LEASE_EXECUTOR = + IoTDBThreadPoolFactory.newSingleThreadScheduledExecutor("SubscriptionOwnerLeaseSyncer"); + + private final ConfigManager configManager; + + private Future leaseSyncFuture; + + SubscriptionOwnerLeaseSyncer(final ConfigManager configManager) { + this.configManager = configManager; + } + + public synchronized void start() { + if (leaseSyncFuture == null) { + leaseSyncFuture = + ScheduledExecutorUtil.safelyScheduleWithFixedDelay( + LEASE_EXECUTOR, + this::sync, + OWNER_LEASE_HEARTBEAT_INTERVAL_SECONDS, + OWNER_LEASE_HEARTBEAT_INTERVAL_SECONDS, + TimeUnit.SECONDS); + LOGGER.info("SubscriptionOwnerLeaseSyncer is started successfully."); + } + } + + private void sync() { + try { + configManager + .getSubscriptionManager() + .getSubscriptionCoordinator() + .pushTopicOwnerLeasesToDataNodes(); + } catch (final Exception e) { + LOGGER.warn("Failed to push subscription topic owner leases to DataNodes.", e); + } + } + + public synchronized void stop() { + if (leaseSyncFuture != null) { + leaseSyncFuture.cancel(false); + leaseSyncFuture = null; + LOGGER.info("SubscriptionOwnerLeaseSyncer is stopped successfully."); + } + } +} diff --git a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfo.java b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfo.java index ee0161ba085e1..a013b3d7f920e 100644 --- a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfo.java +++ b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfo.java @@ -21,6 +21,7 @@ import org.apache.iotdb.common.rpc.thrift.TSStatus; import org.apache.iotdb.commons.snapshot.SnapshotProcessor; +import org.apache.iotdb.commons.subscription.config.SubscriptionConfig; import org.apache.iotdb.commons.subscription.meta.consumer.CommitProgressKeeper; import org.apache.iotdb.commons.subscription.meta.consumer.ConsumerGroupMeta; import org.apache.iotdb.commons.subscription.meta.consumer.ConsumerGroupMetaKeeper; @@ -40,6 +41,7 @@ import org.apache.iotdb.confignode.consensus.response.subscription.SubscriptionTableResp; import org.apache.iotdb.confignode.consensus.response.subscription.TopicTableResp; import org.apache.iotdb.confignode.i18n.ConfigNodeMessages; +import org.apache.iotdb.confignode.i18n.ManagerMessages; import org.apache.iotdb.confignode.rpc.thrift.TCloseConsumerReq; import org.apache.iotdb.confignode.rpc.thrift.TCreateConsumerReq; import org.apache.iotdb.confignode.rpc.thrift.TCreateTopicReq; @@ -47,6 +49,7 @@ import org.apache.iotdb.confignode.rpc.thrift.TUnsubscribeReq; import org.apache.iotdb.consensus.ConsensusFactory; import org.apache.iotdb.consensus.common.DataSet; +import org.apache.iotdb.mpp.rpc.thrift.TTopicOwnerLeaseEntry; import org.apache.iotdb.rpc.TSStatusCode; import org.apache.iotdb.rpc.subscription.config.TopicConfig; import org.apache.iotdb.rpc.subscription.config.TopicConstant; @@ -332,6 +335,20 @@ private void validateTopicConfig(final TopicConfig topicConfig) throws Subscript validateConsensusTableColumnPattern(topicConfig); validateConsensusTopicRetentionConfig(topicConfig); + + final Long ownerLeaseDurationMs = + topicConfig.getLong(TopicConstant.OWNER_LEASE_DURATION_MS_KEY); + final long ownerLeaseDurationMsMin = + SubscriptionConfig.getInstance().getSubscriptionOwnerLeaseDurationMsMin(); + if (Objects.nonNull(ownerLeaseDurationMs) && ownerLeaseDurationMs < ownerLeaseDurationMsMin) { + final String exceptionMessage = + String.format( + ManagerMessages.OWNER_LEASE_DURATION_BELOW_MIN, + ownerLeaseDurationMs, + ownerLeaseDurationMsMin); + LOGGER.warn(exceptionMessage); + throw new SubscriptionException(exceptionMessage); + } } private void validateConsensusProtocolSupport(final TopicConfig topicConfig) @@ -552,6 +569,49 @@ public TopicMeta deepCopyTopicMeta(String topicName) { } } + public TopicMeta deepCopyTopicMetaWithUpdatedAttributes( + String topicName, Map updatedAttributes) { + acquireReadLock(); + try { + return topicMetaKeeper.containsTopicMeta(topicName) + ? topicMetaKeeper.getTopicMeta(topicName).deepCopyWithUpdatedAttributes(updatedAttributes) + : null; + } finally { + releaseReadLock(); + } + } + + /** + * Collect owner-lease entries to push via the dedicated subscription owner heartbeat. Each entry + * carries a relative remaining duration (the configured lease duration); the DataNode derives the + * local expire time on its own clock. Topics undergoing an owner transfer ({@code + * blockedTopicNames}) are skipped so their lease drains. This is read-only on ConfigNode: no + * absolute expire timestamp is ever stored or compared across nodes. + */ + public List collectTopicOwnerLeaseEntries( + final Set blockedTopicNames) { + acquireReadLock(); + try { + final List entries = new ArrayList<>(); + for (final TopicMeta topicMeta : topicMetaKeeper.getAllTopicMeta()) { + if (!topicMeta.isOwnerFencingEnabled() + || Objects.isNull(topicMeta.getOwnerLeaseDurationMs()) + || blockedTopicNames.contains(topicMeta.getTopicName())) { + continue; + } + entries.add( + new TTopicOwnerLeaseEntry( + topicMeta.getTopicName(), + topicMeta.getOwnerId(), + topicMeta.getOwnerEpoch(), + topicMeta.getOwnerLeaseDurationMs())); + } + return entries; + } finally { + releaseReadLock(); + } + } + public DataSet showTopics() { acquireReadLock(); try { @@ -584,6 +644,14 @@ public TSStatus alterTopic(AlterTopicPlan plan) { } private TSStatus alterTopicInternal(final AlterTopicPlan plan) { + try { + TopicMeta.validateOwnerProgression( + topicMetaKeeper.getTopicMeta(plan.getTopicMeta().getTopicName()), plan.getTopicMeta()); + } catch (final IllegalArgumentException e) { + return new TSStatus(TSStatusCode.SUBSCRIPTION_OWNER_EPOCH_CONFLICT.getStatusCode()) + .setMessage(e.getMessage()); + } + topicMetaKeeper.removeTopicMeta(plan.getTopicMeta().getTopicName()); topicMetaKeeper.addTopicMeta(plan.getTopicMeta().getTopicName(), plan.getTopicMeta()); return new TSStatus(TSStatusCode.SUCCESS_STATUS.getStatusCode()); diff --git a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/service/thrift/ConfigNodeRPCServiceProcessor.java b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/service/thrift/ConfigNodeRPCServiceProcessor.java index efd3f8abe8018..5b26a6f7d6dc7 100644 --- a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/service/thrift/ConfigNodeRPCServiceProcessor.java +++ b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/service/thrift/ConfigNodeRPCServiceProcessor.java @@ -102,6 +102,7 @@ import org.apache.iotdb.confignode.rpc.thrift.TAlterPipeReq; import org.apache.iotdb.confignode.rpc.thrift.TAlterSchemaTemplateReq; import org.apache.iotdb.confignode.rpc.thrift.TAlterTimeSeriesReq; +import org.apache.iotdb.confignode.rpc.thrift.TAlterTopicReq; import org.apache.iotdb.confignode.rpc.thrift.TAuthizedPatternTreeResp; import org.apache.iotdb.confignode.rpc.thrift.TAuthorizerRelationalReq; import org.apache.iotdb.confignode.rpc.thrift.TAuthorizerReq; @@ -1275,6 +1276,11 @@ public TSStatus createTopic(TCreateTopicReq req) { return configManager.createTopic(req); } + @Override + public TSStatus alterTopic(TAlterTopicReq req) { + return configManager.alterTopic(req); + } + @Override public TSStatus dropTopic(String topicName) { return configManager.dropTopic( diff --git a/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfoTest.java b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfoTest.java new file mode 100644 index 0000000000000..9b86f1d402af0 --- /dev/null +++ b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfoTest.java @@ -0,0 +1,194 @@ +/* + * 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.confignode.persistence.subscription; + +import org.apache.iotdb.common.rpc.thrift.TSStatus; +import org.apache.iotdb.commons.subscription.meta.topic.TopicMeta; +import org.apache.iotdb.confignode.consensus.request.write.subscription.topic.AlterTopicPlan; +import org.apache.iotdb.confignode.consensus.request.write.subscription.topic.CreateTopicPlan; +import org.apache.iotdb.confignode.consensus.response.subscription.TopicTableResp; +import org.apache.iotdb.confignode.rpc.thrift.TShowTopicInfo; +import org.apache.iotdb.confignode.rpc.thrift.TShowTopicResp; +import org.apache.iotdb.mpp.rpc.thrift.TTopicOwnerLeaseEntry; +import org.apache.iotdb.rpc.TSStatusCode; +import org.apache.iotdb.rpc.subscription.config.TopicConstant; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public class SubscriptionInfoTest { + + @Test + public void testAlterTopicRejectsOwnerEpochRollback() { + final String topicName = "topic-" + UUID.randomUUID(); + final SubscriptionInfo subscriptionInfo = new SubscriptionInfo(); + + final TopicMeta initialTopicMeta = createTopicMeta(topicName, "owner1", 5L); + Assert.assertEquals( + TSStatusCode.SUCCESS_STATUS.getStatusCode(), + subscriptionInfo.createTopic(new CreateTopicPlan(initialTopicMeta)).getCode()); + + final TopicMeta transferredTopicMeta = initialTopicMeta.deepCopy(); + transferredTopicMeta.transferOwner("owner2", 6L); + Assert.assertEquals( + TSStatusCode.SUCCESS_STATUS.getStatusCode(), + subscriptionInfo.alterTopic(new AlterTopicPlan(transferredTopicMeta)).getCode()); + + final TSStatus rollbackStatus = + subscriptionInfo.alterTopic(new AlterTopicPlan(createTopicMeta(topicName, "owner1", 5L))); + + Assert.assertEquals( + TSStatusCode.SUBSCRIPTION_OWNER_EPOCH_CONFLICT.getStatusCode(), rollbackStatus.getCode()); + Assert.assertEquals("owner2", subscriptionInfo.getTopicMeta(topicName).getOwnerId()); + Assert.assertEquals(6L, subscriptionInfo.getTopicMeta(topicName).getOwnerEpoch()); + } + + @Test + public void testAlterTopicTransfersOwnerWithUpdatedAttributes() { + final String topicName = "topic-" + UUID.randomUUID(); + final SubscriptionInfo subscriptionInfo = new SubscriptionInfo(); + + final TopicMeta initialTopicMeta = createTopicMeta(topicName, "owner1", 5L); + initialTopicMeta.getConfig().getAttribute().put(TopicConstant.PATH_KEY, "root.sg.**"); + Assert.assertEquals( + TSStatusCode.SUCCESS_STATUS.getStatusCode(), + subscriptionInfo.createTopic(new CreateTopicPlan(initialTopicMeta)).getCode()); + + final Map updatedAttributes = new HashMap<>(); + updatedAttributes.put(TopicConstant.OWNER_ID_KEY, "owner2"); + updatedAttributes.put(TopicConstant.OWNER_EPOCH_KEY, "6"); + final TSStatus alterStatus = + subscriptionInfo.alterTopic( + new AlterTopicPlan(initialTopicMeta.deepCopyWithUpdatedAttributes(updatedAttributes))); + + Assert.assertEquals(TSStatusCode.SUCCESS_STATUS.getStatusCode(), alterStatus.getCode()); + Assert.assertEquals("owner2", subscriptionInfo.getTopicMeta(topicName).getOwnerId()); + Assert.assertEquals(6L, subscriptionInfo.getTopicMeta(topicName).getOwnerEpoch()); + Assert.assertEquals( + "root.sg.**", + subscriptionInfo.getTopicMeta(topicName).getConfig().getString(TopicConstant.PATH_KEY)); + } + + @Test + public void testCollectTopicOwnerLeaseEntriesSkipsBlockedTopic() { + final String topicName = "topic-" + UUID.randomUUID(); + final String blockedTopicName = "topic-" + UUID.randomUUID(); + final SubscriptionInfo subscriptionInfo = new SubscriptionInfo(); + + final long ownerLeaseDurationMs = 60000; + Assert.assertEquals( + TSStatusCode.SUCCESS_STATUS.getStatusCode(), + subscriptionInfo + .createTopic( + new CreateTopicPlan(createTopicMeta(topicName, "owner1", 5L, ownerLeaseDurationMs))) + .getCode()); + Assert.assertEquals( + TSStatusCode.SUCCESS_STATUS.getStatusCode(), + subscriptionInfo + .createTopic( + new CreateTopicPlan( + createTopicMeta(blockedTopicName, "owner1", 5L, ownerLeaseDurationMs))) + .getCode()); + + final List entries = + subscriptionInfo.collectTopicOwnerLeaseEntries(Collections.singleton(blockedTopicName)); + + Assert.assertEquals(1, entries.size()); + final TTopicOwnerLeaseEntry entry = entries.get(0); + Assert.assertEquals(topicName, entry.getTopicName()); + Assert.assertEquals("owner1", entry.getOwnerId()); + Assert.assertEquals(5L, entry.getOwnerEpoch()); + // Relative remaining duration equals the configured lease duration; read-only, no mutation. + Assert.assertEquals(ownerLeaseDurationMs, entry.getLeaseRemainingMs()); + } + + @Test + public void testAlterTopicOwnerAndShowTopicOwner() { + final String topicName = "topic-" + UUID.randomUUID(); + final long ownerLeaseDurationMs = 123456L; + final SubscriptionInfo subscriptionInfo = new SubscriptionInfo(); + + final TopicMeta initialTopicMeta = createTopicMeta(topicName, "owner1", 5L); + initialTopicMeta.getConfig().getAttribute().put(TopicConstant.PATH_KEY, "root.sg.**"); + initialTopicMeta.getConfig().getAttribute().put(TopicConstant.START_TIME_KEY, "0"); + Assert.assertEquals( + TSStatusCode.SUCCESS_STATUS.getStatusCode(), + subscriptionInfo.createTopic(new CreateTopicPlan(initialTopicMeta)).getCode()); + + final Map updatedAttributes = new HashMap<>(); + updatedAttributes.put(TopicConstant.OWNER_ID_KEY, "owner2"); + updatedAttributes.put(TopicConstant.OWNER_EPOCH_KEY, "6"); + updatedAttributes.put( + TopicConstant.OWNER_LEASE_DURATION_MS_KEY, String.valueOf(ownerLeaseDurationMs)); + final TopicMeta updatedTopicMeta = + initialTopicMeta.deepCopyWithUpdatedAttributes(updatedAttributes); + Assert.assertEquals( + TSStatusCode.SUCCESS_STATUS.getStatusCode(), + subscriptionInfo.alterTopic(new AlterTopicPlan(updatedTopicMeta)).getCode()); + + final TShowTopicResp showTopicResp = + ((TopicTableResp) subscriptionInfo.showTopics()).convertToTShowTopicResp(); + + Assert.assertEquals(TSStatusCode.SUCCESS_STATUS.getStatusCode(), showTopicResp.status.code); + Assert.assertEquals(1, showTopicResp.getTopicInfoListSize()); + + final TShowTopicInfo showTopicInfo = showTopicResp.getTopicInfoList().get(0); + Assert.assertEquals(topicName, showTopicInfo.getTopicName()); + Assert.assertEquals(1L, showTopicInfo.getCreationTime()); + Assert.assertTrue(showTopicInfo.getTopicAttributes().contains("path=root.sg.**")); + Assert.assertTrue(showTopicInfo.getTopicAttributes().contains("start-time=0")); + Assert.assertTrue(showTopicInfo.getTopicAttributes().contains("owner-id=owner2")); + Assert.assertTrue(showTopicInfo.getTopicAttributes().contains("owner-epoch=6")); + Assert.assertTrue( + showTopicInfo + .getTopicAttributes() + .contains("owner-lease-duration-ms=" + ownerLeaseDurationMs)); + // The absolute lease expiry is DataNode-local runtime state; it is never a topic attribute and + // must not appear in SHOW TOPICS output. + Assert.assertFalse(showTopicInfo.getTopicAttributes().contains("owner-lease-expire-time-ms")); + } + + private TopicMeta createTopicMeta( + final String topicName, final String ownerId, final long ownerEpoch) { + final Map topicAttributes = new HashMap<>(); + topicAttributes.put(TopicConstant.OWNER_ID_KEY, ownerId); + topicAttributes.put(TopicConstant.OWNER_EPOCH_KEY, String.valueOf(ownerEpoch)); + return new TopicMeta(topicName, 1, topicAttributes); + } + + private TopicMeta createTopicMeta( + final String topicName, + final String ownerId, + final long ownerEpoch, + final long ownerLeaseDurationMs) { + final Map topicAttributes = new HashMap<>(); + topicAttributes.put(TopicConstant.OWNER_ID_KEY, ownerId); + topicAttributes.put(TopicConstant.OWNER_EPOCH_KEY, String.valueOf(ownerEpoch)); + topicAttributes.put( + TopicConstant.OWNER_LEASE_DURATION_MS_KEY, String.valueOf(ownerLeaseDurationMs)); + return new TopicMeta(topicName, 1, topicAttributes); + } +} diff --git a/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfoTopicValidationTest.java b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfoTopicValidationTest.java index 144b1e9530da1..61eaa8a4c4c64 100644 --- a/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfoTopicValidationTest.java +++ b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/persistence/subscription/SubscriptionInfoTopicValidationTest.java @@ -20,6 +20,7 @@ package org.apache.iotdb.confignode.persistence.subscription; import org.apache.iotdb.commons.pipe.config.constant.SystemConstant; +import org.apache.iotdb.commons.subscription.config.SubscriptionConfig; import org.apache.iotdb.commons.subscription.meta.topic.TopicMeta; import org.apache.iotdb.confignode.consensus.request.write.subscription.topic.CreateTopicPlan; import org.apache.iotdb.confignode.rpc.thrift.TCreateTopicReq; @@ -189,6 +190,33 @@ public void testRejectConsensusOnlyRetentionOnLiveTopic() { assertCreateRejected(subscriptionInfo, attributes, "only supported for consensus topics"); } + @Test + public void testRejectOwnerLeaseDurationBelowMin() { + final SubscriptionInfo subscriptionInfo = new SubscriptionInfo(); + final Map attributes = new HashMap<>(); + attributes.put(TopicConstant.OWNER_ID_KEY, "owner1"); + attributes.put(TopicConstant.OWNER_EPOCH_KEY, "1"); + // Well below the default 1-minute floor. + attributes.put(TopicConstant.OWNER_LEASE_DURATION_MS_KEY, "5000"); + + assertCreateRejected(subscriptionInfo, attributes, "below the minimum allowed"); + } + + @Test + public void testAcceptOwnerLeaseDurationAtMin() throws Exception { + final SubscriptionInfo subscriptionInfo = new SubscriptionInfo(); + final Map attributes = new HashMap<>(); + attributes.put(TopicConstant.OWNER_ID_KEY, "owner1"); + attributes.put(TopicConstant.OWNER_EPOCH_KEY, "1"); + attributes.put( + TopicConstant.OWNER_LEASE_DURATION_MS_KEY, + String.valueOf(SubscriptionConfig.getInstance().getSubscriptionOwnerLeaseDurationMsMin())); + + Assert.assertTrue( + subscriptionInfo.validateBeforeCreatingTopic( + new TCreateTopicReq("owner_topic").setTopicAttributes(attributes))); + } + private static Map newConsensusTableTopicAttributes() { final Map attributes = new HashMap<>(); attributes.put(SystemConstant.SQL_DIALECT_KEY, SystemConstant.SQL_DIALECT_TABLE_VALUE); diff --git a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java index e4a780d13f890..8eb26f05e0a69 100644 --- a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java +++ b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java @@ -184,6 +184,8 @@ private DataNodeMiscMessages() {} "Error occurred when pushing single topic meta"; public static final String ERROR_PUSHING_MULTI_TOPIC_META = "Error occurred when pushing multi topic meta"; + public static final String ERROR_PUSHING_TOPIC_OWNER_LEASE = + "Error occurred when pushing topic owner lease"; public static final String ERROR_PUSHING_CONSUMER_GROUP_META = "Error occurred when pushing consumer group meta"; public static final String ERROR_PUSHING_SINGLE_CONSUMER_GROUP_META = diff --git a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java index 964163ce14fbc..265fc223375e4 100644 --- a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java +++ b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java @@ -551,6 +551,8 @@ public final class DataNodeQueryMessages { "Not support for this sql in START PIPE, please enter pipename."; public static final String NOT_SUPPORT_FOR_THIS_SQL_IN_STOP_PIPE = "Not support for this sql in STOP PIPE, please enter pipename."; + public static final String NOT_SUPPORT_FOR_THIS_SQL_IN_ALTER_TOPIC = + "Not support for this sql in ALTER TOPIC, please enter topicName."; public static final String GET_REGION_ID_STATEMENT_EXPRESSION_MUST_BE_A = "Get region id statement‘ expression must be a time expression"; public static final String WRONG_SPACE_QUOTA_TYPE = diff --git a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java index 14e9b3f2c0000..69e4370b6645b 100644 --- a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java +++ b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeMiscMessages.java @@ -183,6 +183,8 @@ private DataNodeMiscMessages() {} "推送单个 Topic 元数据时发生错误"; public static final String ERROR_PUSHING_MULTI_TOPIC_META = "推送多个 Topic 元数据时发生错误"; + public static final String ERROR_PUSHING_TOPIC_OWNER_LEASE = + "推送 Topic owner 租约时发生错误"; public static final String ERROR_PUSHING_CONSUMER_GROUP_META = "推送消费者组元数据时发生错误"; public static final String ERROR_PUSHING_SINGLE_CONSUMER_GROUP_META = diff --git a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java index ae020f8c5a31f..559eaea448027 100644 --- a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java +++ b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodeQueryMessages.java @@ -550,6 +550,8 @@ public final class DataNodeQueryMessages { "START PIPE 不支持此 SQL,请输入管道名。"; public static final String NOT_SUPPORT_FOR_THIS_SQL_IN_STOP_PIPE = "STOP PIPE 不支持此 SQL,请输入管道名。"; + public static final String NOT_SUPPORT_FOR_THIS_SQL_IN_ALTER_TOPIC = + "ALTER TOPIC 不支持此 SQL,请输入 topic 名。"; public static final String GET_REGION_ID_STATEMENT_EXPRESSION_MUST_BE_A = "GET REGION ID 语句的表达式必须是时间表达式"; public static final String WRONG_SPACE_QUOTA_TYPE = diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/client/ConfigNodeClient.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/client/ConfigNodeClient.java index b21d3a3a79209..e7e50d8b9e502 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/client/ConfigNodeClient.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/client/ConfigNodeClient.java @@ -59,6 +59,7 @@ import org.apache.iotdb.confignode.rpc.thrift.TAlterPipeReq; import org.apache.iotdb.confignode.rpc.thrift.TAlterSchemaTemplateReq; import org.apache.iotdb.confignode.rpc.thrift.TAlterTimeSeriesReq; +import org.apache.iotdb.confignode.rpc.thrift.TAlterTopicReq; import org.apache.iotdb.confignode.rpc.thrift.TAuthizedPatternTreeResp; import org.apache.iotdb.confignode.rpc.thrift.TAuthorizerRelationalReq; import org.apache.iotdb.confignode.rpc.thrift.TAuthorizerReq; @@ -1209,6 +1210,12 @@ public TSStatus createTopic(TCreateTopicReq req) throws TException { () -> client.createTopic(req), status -> !updateConfigNodeLeader(status)); } + @Override + public TSStatus alterTopic(TAlterTopicReq req) throws TException { + return executeRemoteCallWithRetry( + () -> client.alterTopic(req), status -> !updateConfigNodeLeader(status)); + } + @Override public TSStatus dropTopic(String topicName) throws TException { return executeRemoteCallWithRetry( diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/thrift/impl/DataNodeInternalRPCServiceImpl.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/thrift/impl/DataNodeInternalRPCServiceImpl.java index 7890802f36243..fc8a0b37bcaf6 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/thrift/impl/DataNodeInternalRPCServiceImpl.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/protocol/thrift/impl/DataNodeInternalRPCServiceImpl.java @@ -308,6 +308,7 @@ import org.apache.iotdb.mpp.rpc.thrift.TPushTopicMetaReq; import org.apache.iotdb.mpp.rpc.thrift.TPushTopicMetaResp; import org.apache.iotdb.mpp.rpc.thrift.TPushTopicMetaRespExceptionMessage; +import org.apache.iotdb.mpp.rpc.thrift.TPushTopicOwnerLeaseReq; import org.apache.iotdb.mpp.rpc.thrift.TRegionLeaderChangeReq; import org.apache.iotdb.mpp.rpc.thrift.TRegionLeaderChangeResp; import org.apache.iotdb.mpp.rpc.thrift.TRegionMigrateResult; @@ -1546,6 +1547,20 @@ public TPushTopicMetaResp pushMultiTopicMeta(TPushMultiTopicMetaReq req) { } } + @Override + public TSStatus pushTopicOwnerLease(TPushTopicOwnerLeaseReq req) { + if (!SubscriptionConfig.getInstance().getSubscriptionEnabled()) { + return new TSStatus(TSStatusCode.SUCCESS_STATUS.getStatusCode()); + } + try { + SubscriptionAgent.topic().handleTopicOwnerLeases(req.getOwnerLeases()); + return new TSStatus(TSStatusCode.SUCCESS_STATUS.getStatusCode()); + } catch (Exception e) { + LOGGER.warn(DataNodeMiscMessages.ERROR_PUSHING_TOPIC_OWNER_LEASE, e); + return new TSStatus(TSStatusCode.TOPIC_PUSH_META_ERROR.getStatusCode()); + } + } + @Override public TPushConsumerGroupMetaResp pushConsumerGroupMeta(TPushConsumerGroupMetaReq req) { if (!SubscriptionConfig.getInstance().getSubscriptionEnabled()) { diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/TableConfigTaskVisitor.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/TableConfigTaskVisitor.java index 827590e86a011..4eff93bfc12d2 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/TableConfigTaskVisitor.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/TableConfigTaskVisitor.java @@ -140,6 +140,7 @@ import org.apache.iotdb.db.queryengine.plan.execution.config.sys.pipe.ShowPipeTask; import org.apache.iotdb.db.queryengine.plan.execution.config.sys.pipe.StartPipeTask; import org.apache.iotdb.db.queryengine.plan.execution.config.sys.pipe.StopPipeTask; +import org.apache.iotdb.db.queryengine.plan.execution.config.sys.subscription.AlterTopicTask; import org.apache.iotdb.db.queryengine.plan.execution.config.sys.subscription.CreateTopicTask; import org.apache.iotdb.db.queryengine.plan.execution.config.sys.subscription.DropSubscriptionTask; import org.apache.iotdb.db.queryengine.plan.execution.config.sys.subscription.DropTopicTask; @@ -154,6 +155,7 @@ import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AlterColumnDataType; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AlterDB; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AlterPipe; +import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AlterTopic; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AstVisitor; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ClearCache; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ColumnDefinition; @@ -1392,6 +1394,17 @@ public IConfigTask visitCreateTopic(CreateTopic node, MPPQueryContext context) { return new CreateTopicTask(node); } + @Override + public IConfigTask visitAlterTopic(AlterTopic node, MPPQueryContext context) { + context.setQueryType(QueryType.OTHER); + accessControl.checkUserGlobalSysPrivilege(context); + + node.getTopicAttributes() + .put(SystemConstant.SQL_DIALECT_KEY, SystemConstant.SQL_DIALECT_TABLE_VALUE); + + return new AlterTopicTask(node); + } + @Override public IConfigTask visitDropTopic(DropTopic node, MPPQueryContext context) { context.setQueryType(QueryType.OTHER); diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/TreeConfigTaskVisitor.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/TreeConfigTaskVisitor.java index 8b7792bca985a..28745712d220c 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/TreeConfigTaskVisitor.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/TreeConfigTaskVisitor.java @@ -123,6 +123,7 @@ import org.apache.iotdb.db.queryengine.plan.execution.config.sys.quota.SetThrottleQuotaTask; import org.apache.iotdb.db.queryengine.plan.execution.config.sys.quota.ShowSpaceQuotaTask; import org.apache.iotdb.db.queryengine.plan.execution.config.sys.quota.ShowThrottleQuotaTask; +import org.apache.iotdb.db.queryengine.plan.execution.config.sys.subscription.AlterTopicTask; import org.apache.iotdb.db.queryengine.plan.execution.config.sys.subscription.CreateTopicTask; import org.apache.iotdb.db.queryengine.plan.execution.config.sys.subscription.DropSubscriptionTask; import org.apache.iotdb.db.queryengine.plan.execution.config.sys.subscription.DropTopicTask; @@ -192,6 +193,7 @@ import org.apache.iotdb.db.queryengine.plan.statement.metadata.region.MigrateRegionStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.region.ReconstructRegionStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.region.RemoveRegionStatement; +import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.AlterTopicStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.CreateTopicStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.DropSubscriptionStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.DropTopicStatement; @@ -730,6 +732,16 @@ public IConfigTask visitCreateTopic( return new CreateTopicTask(createTopicStatement); } + @Override + public IConfigTask visitAlterTopic( + AlterTopicStatement alterTopicStatement, MPPQueryContext context) { + alterTopicStatement + .getTopicAttributes() + .put(SystemConstant.SQL_DIALECT_KEY, SystemConstant.SQL_DIALECT_TREE_VALUE); + + return new AlterTopicTask(alterTopicStatement); + } + @Override public IConfigTask visitDropTopic( DropTopicStatement dropTopicStatement, MPPQueryContext context) { diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/executor/ClusterConfigTaskExecutor.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/executor/ClusterConfigTaskExecutor.java index 5b8a79cda9a1e..6eda402c2eb2c 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/executor/ClusterConfigTaskExecutor.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/executor/ClusterConfigTaskExecutor.java @@ -106,6 +106,7 @@ import org.apache.iotdb.confignode.rpc.thrift.TAlterPipeReq; import org.apache.iotdb.confignode.rpc.thrift.TAlterSchemaTemplateReq; import org.apache.iotdb.confignode.rpc.thrift.TAlterTimeSeriesReq; +import org.apache.iotdb.confignode.rpc.thrift.TAlterTopicReq; import org.apache.iotdb.confignode.rpc.thrift.TCountDatabaseResp; import org.apache.iotdb.confignode.rpc.thrift.TCountTimeSlotListReq; import org.apache.iotdb.confignode.rpc.thrift.TCountTimeSlotListResp; @@ -291,6 +292,7 @@ import org.apache.iotdb.db.queryengine.plan.statement.metadata.pipe.ShowPipesStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.pipe.StartPipeStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.pipe.StopPipeStatement; +import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.AlterTopicStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.CreateTopicStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.DropSubscriptionStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.DropTopicStatement; @@ -2882,6 +2884,34 @@ public SettableFuture createTopic( return future; } + @Override + public SettableFuture alterTopic( + final AlterTopicStatement alterTopicStatement) { + if (!SubscriptionConfig.getInstance().getSubscriptionEnabled()) { + return SUBSCRIPTION_NOT_ENABLED_ERROR_FUTURE; + } + + final SettableFuture future = SettableFuture.create(); + + try (final ConfigNodeClient configNodeClient = + CONFIG_NODE_CLIENT_MANAGER.borrowClient(ConfigNodeInfo.CONFIG_REGION_ID)) { + final TSStatus tsStatus = + configNodeClient.alterTopic( + new TAlterTopicReq() + .setTopicName(alterTopicStatement.getTopicName()) + .setTopicAttributes(alterTopicStatement.getTopicAttributes()) + .setSubscribedConsumerGroupIds(Collections.emptySet())); + if (TSStatusCode.SUCCESS_STATUS.getStatusCode() != tsStatus.getCode()) { + future.setException(new IoTDBException(tsStatus)); + } else { + future.set(new ConfigTaskResult(TSStatusCode.SUCCESS_STATUS)); + } + } catch (Exception e) { + future.setException(e); + } + return future; + } + @Override public SettableFuture dropTopic(final DropTopicStatement dropTopicStatement) { if (!SubscriptionConfig.getInstance().getSubscriptionEnabled()) { diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/executor/IConfigTaskExecutor.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/executor/IConfigTaskExecutor.java index b45209aeffed8..4e551983d735d 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/executor/IConfigTaskExecutor.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/executor/IConfigTaskExecutor.java @@ -76,6 +76,7 @@ import org.apache.iotdb.db.queryengine.plan.statement.metadata.pipe.ShowPipesStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.pipe.StartPipeStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.pipe.StopPipeStatement; +import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.AlterTopicStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.CreateTopicStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.DropSubscriptionStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.DropTopicStatement; @@ -243,6 +244,8 @@ SettableFuture dropSubscription( SettableFuture createTopic(CreateTopicStatement createTopicStatement); + SettableFuture alterTopic(AlterTopicStatement alterTopicStatement); + SettableFuture dropTopic(DropTopicStatement dropTopicStatement); SettableFuture showTopics(ShowTopicsStatement showTopicsStatement); diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/sys/subscription/AlterTopicTask.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/sys/subscription/AlterTopicTask.java new file mode 100644 index 0000000000000..33ae342267be2 --- /dev/null +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/sys/subscription/AlterTopicTask.java @@ -0,0 +1,49 @@ +/* + * 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.db.queryengine.plan.execution.config.sys.subscription; + +import org.apache.iotdb.db.queryengine.plan.execution.config.ConfigTaskResult; +import org.apache.iotdb.db.queryengine.plan.execution.config.IConfigTask; +import org.apache.iotdb.db.queryengine.plan.execution.config.executor.IConfigTaskExecutor; +import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AlterTopic; +import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.AlterTopicStatement; + +import com.google.common.util.concurrent.ListenableFuture; + +public class AlterTopicTask implements IConfigTask { + + private final AlterTopicStatement alterTopicStatement; + + public AlterTopicTask(final AlterTopicStatement alterTopicStatement) { + this.alterTopicStatement = alterTopicStatement; + } + + public AlterTopicTask(final AlterTopic alterTopic) { + this.alterTopicStatement = new AlterTopicStatement(); + this.alterTopicStatement.setTopicName(alterTopic.getTopicName()); + this.alterTopicStatement.setTopicAttributes(alterTopic.getTopicAttributes()); + } + + @Override + public ListenableFuture execute(final IConfigTaskExecutor configTaskExecutor) + throws InterruptedException { + return configTaskExecutor.alterTopic(alterTopicStatement); + } +} diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/parser/ASTVisitor.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/parser/ASTVisitor.java index 49f79a6a923c0..6ecf65c86bb3c 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/parser/ASTVisitor.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/parser/ASTVisitor.java @@ -214,6 +214,7 @@ import org.apache.iotdb.db.queryengine.plan.statement.metadata.region.MigrateRegionStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.region.ReconstructRegionStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.region.RemoveRegionStatement; +import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.AlterTopicStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.CreateTopicStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.DropSubscriptionStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.DropTopicStatement; @@ -4436,6 +4437,22 @@ public Statement visitCreateTopic(IoTDBSqlParser.CreateTopicContext ctx) { return createTopicStatement; } + @Override + public Statement visitAlterTopic(IoTDBSqlParser.AlterTopicContext ctx) { + final AlterTopicStatement alterTopicStatement = new AlterTopicStatement(); + + if (ctx.topicName != null) { + alterTopicStatement.setTopicName(parseIdentifier(ctx.topicName.getText())); + } else { + throw new SemanticException(DataNodeQueryMessages.NOT_SUPPORT_FOR_THIS_SQL_IN_ALTER_TOPIC); + } + + alterTopicStatement.setTopicAttributes( + parseTopicAttributesClause(ctx.topicAttributesClause().topicAttributeClause())); + + return alterTopicStatement; + } + private Map parseTopicAttributesClause( List contexts) { final Map collectorMap = new HashMap<>(); diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java index 8912ac37264a4..81c3c24a7ccb5 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java @@ -147,6 +147,7 @@ import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AddColumn; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AlterDB; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AlterPipe; +import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AlterTopic; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AsofJoinOn; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AstVisitor; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.CopyTo; @@ -4859,6 +4860,11 @@ public Scope visitCreateTopic(CreateTopic node, Optional context) { return createAndAssignScope(node, context); } + @Override + public Scope visitAlterTopic(AlterTopic node, Optional context) { + return createAndAssignScope(node, context); + } + @Override public Scope visitDropTopic(DropTopic node, Optional context) { return createAndAssignScope(node, context); diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/security/TreeAccessCheckVisitor.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/security/TreeAccessCheckVisitor.java index fd9757c5f8902..41f91bb461809 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/security/TreeAccessCheckVisitor.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/security/TreeAccessCheckVisitor.java @@ -122,6 +122,7 @@ import org.apache.iotdb.db.queryengine.plan.statement.metadata.region.MigrateRegionStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.region.ReconstructRegionStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.region.RemoveRegionStatement; +import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.AlterTopicStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.CreateTopicStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.DropSubscriptionStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.DropTopicStatement; @@ -884,6 +885,12 @@ public TSStatus visitCreateTopic(CreateTopicStatement statement, TreeAccessCheck context.setAuditLogOperation(AuditLogOperation.DDL), statement::getTopicName); } + @Override + public TSStatus visitAlterTopic(AlterTopicStatement statement, TreeAccessCheckContext context) { + return checkPipeManagement( + context.setAuditLogOperation(AuditLogOperation.DDL), statement::getTopicName); + } + @Override public TSStatus visitShowTopics(ShowTopicsStatement statement, TreeAccessCheckContext context) { return checkPipeManagement( diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/AlterTopic.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/AlterTopic.java new file mode 100644 index 0000000000000..8598c1525266d --- /dev/null +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/AlterTopic.java @@ -0,0 +1,92 @@ +/* + * 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.db.queryengine.plan.relational.sql.ast; + +import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.AstMemoryEstimationHelper; +import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.IAstVisitor; + +import org.apache.tsfile.utils.RamUsageEstimator; + +import java.util.Map; +import java.util.Objects; + +import static com.google.common.base.MoreObjects.toStringHelper; +import static java.util.Objects.requireNonNull; + +public class AlterTopic extends SubscriptionStatement { + private static final long INSTANCE_SIZE = + RamUsageEstimator.shallowSizeOfInstance(AlterTopic.class); + + private final String topicName; + private final Map topicAttributes; + + public AlterTopic(final String topicName, final Map topicAttributes) { + this.topicName = requireNonNull(topicName, "topic name can not be null"); + this.topicAttributes = requireNonNull(topicAttributes, "topic attributes can not be null"); + } + + public String getTopicName() { + return topicName; + } + + public Map getTopicAttributes() { + return topicAttributes; + } + + @Override + public R accept(final IAstVisitor visitor, final C context) { + return ((AstVisitor) visitor).visitAlterTopic(this, context); + } + + @Override + public int hashCode() { + return Objects.hash(topicName, topicAttributes); + } + + @Override + public boolean equals(final Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + final AlterTopic that = (AlterTopic) obj; + return Objects.equals(this.topicName, that.topicName) + && Objects.equals(this.topicAttributes, that.topicAttributes); + } + + @Override + public String toString() { + return toStringHelper(this) + .add("topicName", topicName) + .add("topicAttributes", topicAttributes) + .toString(); + } + + @Override + public long ramBytesUsed() { + long size = INSTANCE_SIZE; + size += AstMemoryEstimationHelper.getEstimatedSizeOfNodeLocation(getLocationInternal()); + size += RamUsageEstimator.sizeOf(topicName); + size += RamUsageEstimator.sizeOfMap(topicAttributes); + return size; + } +} diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/AstVisitor.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/AstVisitor.java index 7c311fa7f62b6..c00b7f4d492a8 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/AstVisitor.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/AstVisitor.java @@ -343,6 +343,10 @@ default R visitCreateTopic(CreateTopic node, C context) { return visitStatement(node, context); } + default R visitAlterTopic(AlterTopic node, C context) { + return visitStatement(node, context); + } + default R visitDropTopic(DropTopic node, C context) { return visitStatement(node, context); } diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java index a19168b50f42f..43dc3ccc31b64 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java @@ -159,6 +159,7 @@ import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AlterColumnDataType; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AlterDB; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AlterPipe; +import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AlterTopic; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AsofJoinOn; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ClearCache; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ColumnDefinition; @@ -1391,6 +1392,13 @@ public Node visitCreateTopicStatement(RelationalSqlParser.CreateTopicStatementCo return new CreateTopic(topicName, hasIfNotExistsCondition, topicAttributes); } + @Override + public Node visitAlterTopicStatement(RelationalSqlParser.AlterTopicStatementContext ctx) { + final String topicName = ((Identifier) visit(ctx.identifier())).getValue(); + return new AlterTopic( + topicName, parseTopicAttributesClause(ctx.topicAttributesClause().topicAttributeClause())); + } + private Map parseTopicAttributesClause( List contexts) { final Map tppicMap = new HashMap<>(); diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/util/DataNodeSqlFormatter.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/util/DataNodeSqlFormatter.java index b806fd725928e..887a6082a5d5d 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/util/DataNodeSqlFormatter.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/util/DataNodeSqlFormatter.java @@ -29,6 +29,7 @@ import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AddColumn; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AlterDB; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AlterPipe; +import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AlterTopic; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AstVisitor; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ColumnDefinition; import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.CopyTo; @@ -771,6 +772,31 @@ public Void visitCreateTopic(CreateTopic node, Integer context) { return null; } + @Override + public Void visitAlterTopic(AlterTopic node, Integer context) { + builder.append("ALTER TOPIC "); + builder.append(node.getTopicName()); + builder.append(" \n"); + + builder + .append("WITH (") + .append("\n") + .append( + node.getTopicAttributes().entrySet().stream() + .map( + entry -> + indentString(1) + + "\"" + + entry.getKey() + + "\" = \"" + + entry.getValue() + + "\"") + .collect(joining(", " + "\n"))) + .append(")\n"); + + return null; + } + @Override public Void visitDropTopic(DropTopic node, Integer context) { builder.append("DROP TOPIC "); diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/StatementType.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/StatementType.java index b40c6444816fc..88a2959f1475b 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/StatementType.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/StatementType.java @@ -184,6 +184,7 @@ public enum StatementType { REPAIR_DATA_PARTITION_TABLE, CREATE_TOPIC, + ALTER_TOPIC, DROP_TOPIC, SHOW_TOPICS, SHOW_SUBSCRIPTIONS, diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/StatementVisitor.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/StatementVisitor.java index 847e850c52172..39af259928b6b 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/StatementVisitor.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/StatementVisitor.java @@ -107,6 +107,7 @@ import org.apache.iotdb.db.queryengine.plan.statement.metadata.region.MigrateRegionStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.region.ReconstructRegionStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.region.RemoveRegionStatement; +import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.AlterTopicStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.CreateTopicStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.DropSubscriptionStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.DropTopicStatement; @@ -644,6 +645,10 @@ public R visitCreateTopic(CreateTopicStatement createTopicStatement, C context) return visitStatement(createTopicStatement, context); } + public R visitAlterTopic(AlterTopicStatement alterTopicStatement, C context) { + return visitStatement(alterTopicStatement, context); + } + public R visitDropTopic(DropTopicStatement dropTopicStatement, C context) { return visitStatement(dropTopicStatement, context); } diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/metadata/subscription/AlterTopicStatement.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/metadata/subscription/AlterTopicStatement.java new file mode 100644 index 0000000000000..dd06b3034b00b --- /dev/null +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/statement/metadata/subscription/AlterTopicStatement.java @@ -0,0 +1,73 @@ +/* + * 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.db.queryengine.plan.statement.metadata.subscription; + +import org.apache.iotdb.commons.path.PartialPath; +import org.apache.iotdb.db.queryengine.plan.analyze.QueryType; +import org.apache.iotdb.db.queryengine.plan.statement.IConfigStatement; +import org.apache.iotdb.db.queryengine.plan.statement.Statement; +import org.apache.iotdb.db.queryengine.plan.statement.StatementType; +import org.apache.iotdb.db.queryengine.plan.statement.StatementVisitor; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class AlterTopicStatement extends Statement implements IConfigStatement { + + private String topicName; + private Map topicAttributes; + + public AlterTopicStatement() { + super(); + statementType = StatementType.ALTER_TOPIC; + } + + public String getTopicName() { + return topicName; + } + + public Map getTopicAttributes() { + return topicAttributes; + } + + public void setTopicName(final String topicName) { + this.topicName = topicName; + } + + public void setTopicAttributes(final Map topicAttributes) { + this.topicAttributes = topicAttributes; + } + + @Override + public QueryType getQueryType() { + return QueryType.OTHER; + } + + @Override + public List getPaths() { + return Collections.emptyList(); + } + + @Override + public R accept(final StatementVisitor visitor, final C context) { + return visitor.visitAlterTopic(this, context); + } +} diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionTopicAgent.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionTopicAgent.java index e89578fe556e6..485e553e8ee0a 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionTopicAgent.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionTopicAgent.java @@ -19,10 +19,15 @@ package org.apache.iotdb.db.subscription.agent; +import org.apache.iotdb.common.rpc.thrift.TSStatus; import org.apache.iotdb.commons.subscription.meta.topic.TopicMeta; import org.apache.iotdb.commons.subscription.meta.topic.TopicMetaKeeper; import org.apache.iotdb.db.i18n.DataNodeMiscMessages; import org.apache.iotdb.mpp.rpc.thrift.TPushTopicMetaRespExceptionMessage; +import org.apache.iotdb.mpp.rpc.thrift.TTopicOwnerLeaseEntry; +import org.apache.iotdb.rpc.RpcUtils; +import org.apache.iotdb.rpc.TSStatusCode; +import org.apache.iotdb.rpc.subscription.config.ConsumerConfig; import org.apache.iotdb.rpc.subscription.config.TopicConfig; import org.apache.iotdb.rpc.subscription.config.TopicConstant; @@ -31,6 +36,7 @@ import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -87,6 +93,8 @@ public TPushTopicMetaRespExceptionMessage handleSingleTopicMetaChanges( private void handleSingleTopicMetaChangesInternal(final TopicMeta metaFromCoordinator) { final String topicName = metaFromCoordinator.getTopicName(); + TopicMeta.validateOwnerProgression( + topicMetaKeeper.getTopicMeta(topicName), metaFromCoordinator); topicMetaKeeper.removeTopicMeta(topicName); topicMetaKeeper.addTopicMeta(topicName, metaFromCoordinator); SubscriptionAgent.broker() @@ -196,4 +204,94 @@ public Map getTopicConfigs(final Set topicNames) { releaseReadLock(); } } + + public TSStatus checkTopicOwner(final ConsumerConfig consumerConfig, final String topicName) { + acquireReadLock(); + try { + final TopicMeta topicMeta = topicMetaKeeper.getTopicMeta(topicName); + if (Objects.isNull(topicMeta) || !topicMeta.isOwnerFencingEnabled()) { + return RpcUtils.SUCCESS_STATUS; + } + + final String requestOwnerId = consumerConfig.getOwnerId(); + if (Objects.isNull(requestOwnerId)) { + return RpcUtils.getStatus( + TSStatusCode.SUBSCRIPTION_OWNER_REQUIRED, + String.format( + "Subscription: topic %s enables owner fencing, but consumer %s does not carry owner-id.", + topicName, consumerConfig)); + } + + final Long requestOwnerEpoch = consumerConfig.getOwnerEpoch(); + if (Objects.isNull(requestOwnerEpoch)) { + return RpcUtils.getStatus( + TSStatusCode.SUBSCRIPTION_OWNER_EPOCH_REQUIRED, + String.format( + "Subscription: topic %s enables owner fencing, but consumer %s does not carry owner-epoch.", + topicName, consumerConfig)); + } + + if (Objects.nonNull(topicMeta.getOwnerLeaseExpireTimeMs()) + && System.currentTimeMillis() > topicMeta.getOwnerLeaseExpireTimeMs()) { + return RpcUtils.getStatus( + TSStatusCode.SUBSCRIPTION_OWNER_LEASE_EXPIRED, + String.format( + "Subscription: owner lease for topic %s has expired, owner-id: %s, owner-epoch: %s.", + topicName, topicMeta.getOwnerId(), topicMeta.getOwnerEpoch())); + } + + if (!topicMeta.matchesOwner(requestOwnerId, requestOwnerEpoch)) { + return RpcUtils.getStatus( + TSStatusCode.SUBSCRIPTION_OWNER_FENCED, + String.format( + "Subscription: consumer owner is fenced for topic %s, request owner-id: %s," + + " request owner-epoch: %s, current owner-id: %s, current owner-epoch: %s.", + topicName, + requestOwnerId, + requestOwnerEpoch, + topicMeta.getOwnerId(), + topicMeta.getOwnerEpoch())); + } + + return RpcUtils.SUCCESS_STATUS; + } finally { + releaseReadLock(); + } + } + + public TSStatus checkTopicOwners( + final ConsumerConfig consumerConfig, final Iterable topicNames) { + for (final String topicName : topicNames) { + final TSStatus status = checkTopicOwner(consumerConfig, topicName); + if (status.getCode() != TSStatusCode.SUCCESS_STATUS.getStatusCode()) { + return status; + } + } + return RpcUtils.SUCCESS_STATUS; + } + + /** + * Apply owner lease renewals pushed by ConfigNode via the dedicated subscription owner heartbeat. + * The pushed remaining duration is converted to a DataNode-local expire time on the local clock, + * so no absolute timestamp is compared across nodes. Owner identity/epoch changes are delivered + * via the topic-meta push path; here we only refresh the lease for the matching current owner. + */ + public void handleTopicOwnerLeases(final List ownerLeases) { + if (Objects.isNull(ownerLeases) || ownerLeases.isEmpty()) { + return; + } + acquireWriteLock(); + try { + for (final TTopicOwnerLeaseEntry lease : ownerLeases) { + final TopicMeta topicMeta = topicMetaKeeper.getTopicMeta(lease.getTopicName()); + if (Objects.isNull(topicMeta)) { + continue; + } + topicMeta.applyOwnerLeaseFromHeartbeat( + lease.getOwnerId(), lease.getOwnerEpoch(), lease.getLeaseRemainingMs()); + } + } finally { + releaseWriteLock(); + } + } } diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1.java index 3c76d09c88dd5..134d6f7b90db5 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1.java @@ -347,15 +347,22 @@ private TPipeSubscribeResp handleSubscriptionHeartbeatInternal(final Subscriptio processorBufferedCommitContexts.size()); } + final Set subscribedTopicNames = + SubscriptionAgent.consumer() + .getTopicNamesSubscribedByConsumer( + consumerConfig.getConsumerGroupId(), consumerConfig.getConsumerId()); + + final TSStatus ownerStatus = + SubscriptionAgent.topic().checkTopicOwners(consumerConfig, subscribedTopicNames); + if (ownerStatus.getCode() != TSStatusCode.SUCCESS_STATUS.getStatusCode()) { + return PipeSubscribeHeartbeatResp.toTPipeSubscribeResp(ownerStatus); + } + LOGGER.info(DataNodeMiscMessages.SUBSCRIPTION_CONSUMER_HEARTBEAT_SUCCESS, consumerConfig); // fetch subscribed topics final Map topics = - SubscriptionAgent.topic() - .getTopicConfigs( - SubscriptionAgent.consumer() - .getTopicNamesSubscribedByConsumer( - consumerConfig.getConsumerGroupId(), consumerConfig.getConsumerId())); + SubscriptionAgent.topic().getTopicConfigs(subscribedTopicNames); // fetch available endpoints final Map endPoints = new HashMap<>(); @@ -426,6 +433,11 @@ private TPipeSubscribeResp handlePipeSubscribeSubscribeInternal( // subscribe topics final Set topicNames = req.getTopicNames(); + final TSStatus ownerStatus = + SubscriptionAgent.topic().checkTopicOwners(consumerConfig, topicNames); + if (ownerStatus.getCode() != TSStatusCode.SUCCESS_STATUS.getStatusCode()) { + return PipeSubscribeSubscribeResp.toTPipeSubscribeResp(ownerStatus); + } subscribe(consumerConfig, topicNames); LOGGER.info( @@ -518,6 +530,18 @@ private TPipeSubscribeResp handlePipeSubscribePollInternal(final PipeSubscribePo if (SubscriptionPollRequestType.isValidatedRequestType(requestType)) { switch (SubscriptionPollRequestType.valueOf(requestType)) { case POLL: + final Set pollTopicNames = ((PollPayload) request.getPayload()).getTopicNames(); + final Set subscribedTopicNames = + SubscriptionAgent.consumer() + .getTopicNamesSubscribedByConsumer( + consumerConfig.getConsumerGroupId(), consumerConfig.getConsumerId()); + final Set topicNamesToCheck = new HashSet<>(pollTopicNames); + topicNamesToCheck.removeIf(topicName -> !subscribedTopicNames.contains(topicName)); + final TSStatus ownerStatus = + SubscriptionAgent.topic().checkTopicOwners(consumerConfig, topicNamesToCheck); + if (ownerStatus.getCode() != TSStatusCode.SUCCESS_STATUS.getStatusCode()) { + return PipeSubscribePollResp.toTPipeSubscribeResp(ownerStatus, Collections.emptyList()); + } events = handlePipeSubscribePollRequest( consumerConfig, @@ -526,11 +550,31 @@ private TPipeSubscribeResp handlePipeSubscribePollInternal(final PipeSubscribePo request.getProgressByTopic()); break; case POLL_FILE: + final TSStatus tsFileOwnerStatus = + SubscriptionAgent.topic() + .checkTopicOwner( + consumerConfig, + ((PollFilePayload) request.getPayload()).getCommitContext().getTopicName()); + if (tsFileOwnerStatus.getCode() != TSStatusCode.SUCCESS_STATUS.getStatusCode()) { + return PipeSubscribePollResp.toTPipeSubscribeResp( + tsFileOwnerStatus, Collections.emptyList()); + } events = handlePipeSubscribePollTsFileRequest( consumerConfig, (PollFilePayload) request.getPayload()); break; case POLL_TABLETS: + final TSStatus tabletsOwnerStatus = + SubscriptionAgent.topic() + .checkTopicOwner( + consumerConfig, + ((PollTabletsPayload) request.getPayload()) + .getCommitContext() + .getTopicName()); + if (tabletsOwnerStatus.getCode() != TSStatusCode.SUCCESS_STATUS.getStatusCode()) { + return PipeSubscribePollResp.toTPipeSubscribeResp( + tabletsOwnerStatus, Collections.emptyList()); + } events = handlePipeSubscribePollTabletsRequest( consumerConfig, (PollTabletsPayload) request.getPayload()); @@ -709,6 +753,16 @@ private TPipeSubscribeResp handlePipeSubscribeCommitInternal(final PipeSubscribe // commit (ack or nack) final List commitContexts = req.getCommitContexts(); + final TSStatus ownerStatus = + SubscriptionAgent.topic() + .checkTopicOwners( + consumerConfig, + commitContexts.stream() + .map(SubscriptionCommitContext::getTopicName) + .collect(Collectors.toSet())); + if (ownerStatus.getCode() != TSStatusCode.SUCCESS_STATUS.getStatusCode()) { + return PipeSubscribeCommitResp.toTPipeSubscribeResp(ownerStatus); + } final boolean nack = req.isNack(); final Set subscribedTopicNames = SubscriptionAgent.consumer() @@ -885,6 +939,15 @@ private TPipeSubscribeResp handleSubscriptionSeekInternal(final SubscriptionSeek final String topicName = req.getTopicName(); final short seekType = req.getSeekType(); + // Owner fencing: seek mutates the consumption progress, so a stale owner must not be allowed to + // move the position after an ownership transfer (otherwise it could corrupt the new owner's + // recovery point). This must be guarded just like commit. + final TSStatus ownerStatus = + SubscriptionAgent.topic().checkTopicOwner(consumerConfig, topicName); + if (ownerStatus.getCode() != TSStatusCode.SUCCESS_STATUS.getStatusCode()) { + return PipeSubscribeSeekResp.toTPipeSubscribeResp(ownerStatus); + } + if (seekType == SubscriptionSeekReq.SEEK_TO_TOPIC_PROGRESS) { SubscriptionAgent.broker() .seekToTopicProgress(consumerConfig, topicName, req.getTopicProgress()); diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/parser/StatementGeneratorTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/parser/StatementGeneratorTest.java index b98f34a2484d9..febf29bea1553 100644 --- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/parser/StatementGeneratorTest.java +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/parser/StatementGeneratorTest.java @@ -56,6 +56,7 @@ import org.apache.iotdb.db.queryengine.plan.statement.metadata.DatabaseSchemaStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.DeleteDatabaseStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.DeleteTimeSeriesStatement; +import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.AlterTopicStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.template.BatchActivateTemplateStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.template.CreateSchemaTemplateStatement; import org.apache.iotdb.db.queryengine.plan.statement.metadata.template.DropSchemaTemplateStatement; @@ -121,6 +122,20 @@ public class StatementGeneratorTest { + @Test + public void testAlterTopicStatement() { + final Statement statement = + StatementGenerator.createStatement( + "ALTER TOPIC topic1 WITH ('owner-id'='owner2','owner-epoch'='6')", + ZonedDateTime.now().getOffset()); + + Assert.assertTrue(statement instanceof AlterTopicStatement); + final AlterTopicStatement alterTopicStatement = (AlterTopicStatement) statement; + Assert.assertEquals("topic1", alterTopicStatement.getTopicName()); + Assert.assertEquals("owner2", alterTopicStatement.getTopicAttributes().get("owner-id")); + Assert.assertEquals("6", alterTopicStatement.getTopicAttributes().get("owner-epoch")); + } + @Test public void testShowDiskUsage() { diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/TopicStatementTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/TopicStatementTest.java new file mode 100644 index 0000000000000..d457bdbe55f44 --- /dev/null +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/TopicStatementTest.java @@ -0,0 +1,48 @@ +/* + * 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.db.queryengine.plan.relational.sql.parser; + +import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Statement; +import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AlterTopic; + +import org.junit.Assert; +import org.junit.Test; + +import java.time.ZoneId; + +public class TopicStatementTest { + + private final SqlParser sqlParser = new SqlParser(); + + @Test + public void testAlterTopicStatement() { + final Statement statement = + sqlParser.createStatement( + "ALTER TOPIC topic1 WITH ('owner-id'='owner2','owner-epoch'='6')", + ZoneId.systemDefault(), + null); + + Assert.assertTrue(statement instanceof AlterTopic); + final AlterTopic alterTopic = (AlterTopic) statement; + Assert.assertEquals("topic1", alterTopic.getTopicName()); + Assert.assertEquals("owner2", alterTopic.getTopicAttributes().get("owner-id")); + Assert.assertEquals("6", alterTopic.getTopicAttributes().get("owner-epoch")); + } +} diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1Test.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1Test.java index ba0070187e368..0890ae8100aaa 100644 --- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1Test.java +++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/receiver/SubscriptionReceiverV1Test.java @@ -20,14 +20,19 @@ package org.apache.iotdb.db.subscription.receiver; import org.apache.iotdb.commons.subscription.config.SubscriptionConfig; +import org.apache.iotdb.commons.subscription.meta.topic.TopicMeta; +import org.apache.iotdb.db.subscription.agent.SubscriptionAgent; +import org.apache.iotdb.rpc.TSStatusCode; import org.apache.iotdb.rpc.subscription.config.ConsumerConfig; import org.apache.iotdb.rpc.subscription.config.ConsumerConstant; +import org.apache.iotdb.rpc.subscription.config.TopicConstant; import org.junit.Assert; import org.junit.Test; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -82,6 +87,77 @@ public void testCalculateConsumerInactivityTimeoutUsesHeartbeatMultiple() throws invokeCalculateConsumerInactivityTimeoutMs(receiver, createConsumerConfig(5_000L))); } + @Test + public void testTopicOwnerFencingStatus() { + final String topicName = "topic-" + UUID.randomUUID(); + + SubscriptionAgent.topic() + .handleSingleTopicMetaChanges(createTopicMeta(topicName, "owner1", 7L)); + try { + Assert.assertEquals( + TSStatusCode.SUCCESS_STATUS.getStatusCode(), + SubscriptionAgent.topic() + .checkTopicOwner(createConsumerConfig(1_000L, "owner1", 7L), topicName) + .getCode()); + Assert.assertEquals( + TSStatusCode.SUBSCRIPTION_OWNER_FENCED.getStatusCode(), + SubscriptionAgent.topic() + .checkTopicOwner(createConsumerConfig(1_000L, "owner2", 7L), topicName) + .getCode()); + Assert.assertEquals( + TSStatusCode.SUBSCRIPTION_OWNER_REQUIRED.getStatusCode(), + SubscriptionAgent.topic() + .checkTopicOwner(createConsumerConfig(1_000L), topicName) + .getCode()); + } finally { + SubscriptionAgent.topic().handleDropTopic(topicName); + } + } + + @Test + public void testOldOwnerFencedAfterNetworkPartitionAndTopicOwnerTransfer() { + final String topicName = "topic-" + UUID.randomUUID(); + final TopicMeta topicMeta = createTopicMeta(topicName, "owner1", 5L); + final ConsumerConfig oldOwnerConsumer = createConsumerConfig(1_000L, "owner1", 5L); + final ConsumerConfig newOwnerConsumer = createConsumerConfig(1_000L, "owner2", 6L); + + SubscriptionAgent.topic().handleSingleTopicMetaChanges(topicMeta); + try { + Assert.assertEquals( + TSStatusCode.SUCCESS_STATUS.getStatusCode(), + SubscriptionAgent.topic().checkTopicOwner(oldOwnerConsumer, topicName).getCode()); + + final TopicMeta transferredTopicMeta = topicMeta.deepCopy(); + transferredTopicMeta.transferOwner("owner2", 6L); + SubscriptionAgent.topic().handleSingleTopicMetaChanges(transferredTopicMeta); + + Assert.assertEquals( + TSStatusCode.SUBSCRIPTION_OWNER_FENCED.getStatusCode(), + SubscriptionAgent.topic().checkTopicOwner(oldOwnerConsumer, topicName).getCode()); + Assert.assertEquals( + TSStatusCode.SUBSCRIPTION_OWNER_FENCED.getStatusCode(), + SubscriptionAgent.topic() + .checkTopicOwners(oldOwnerConsumer, Collections.singleton(topicName)) + .getCode()); + Assert.assertNotNull( + SubscriptionAgent.topic() + .handleSingleTopicMetaChanges(createTopicMeta(topicName, "owner1", 5L))); + Assert.assertEquals( + TSStatusCode.SUBSCRIPTION_OWNER_FENCED.getStatusCode(), + SubscriptionAgent.topic().checkTopicOwner(oldOwnerConsumer, topicName).getCode()); + Assert.assertEquals( + TSStatusCode.SUCCESS_STATUS.getStatusCode(), + SubscriptionAgent.topic().checkTopicOwner(newOwnerConsumer, topicName).getCode()); + Assert.assertEquals( + TSStatusCode.SUCCESS_STATUS.getStatusCode(), + SubscriptionAgent.topic() + .checkTopicOwners(newOwnerConsumer, Collections.singleton(topicName)) + .getCode()); + } finally { + SubscriptionAgent.topic().handleDropTopic(topicName); + } + } + private long invokeCalculateConsumerInactivityTimeoutMs( final SubscriptionReceiverV1 receiver, final ConsumerConfig consumerConfig) throws Exception { final Method method = @@ -91,11 +167,30 @@ private long invokeCalculateConsumerInactivityTimeoutMs( return (long) method.invoke(receiver, consumerConfig); } + private TopicMeta createTopicMeta( + final String topicName, final String ownerId, final long ownerEpoch) { + final Map topicAttributes = new HashMap<>(); + topicAttributes.put(TopicConstant.OWNER_ID_KEY, ownerId); + topicAttributes.put(TopicConstant.OWNER_EPOCH_KEY, String.valueOf(ownerEpoch)); + return new TopicMeta(topicName, 1, topicAttributes); + } + private ConsumerConfig createConsumerConfig(final long heartbeatIntervalMs) { + return createConsumerConfig(heartbeatIntervalMs, null, null); + } + + private ConsumerConfig createConsumerConfig( + final long heartbeatIntervalMs, final String ownerId, final Long ownerEpoch) { final Map attributes = new HashMap<>(); attributes.put(ConsumerConstant.CONSUMER_ID_KEY, "consumer-" + UUID.randomUUID()); attributes.put(ConsumerConstant.CONSUMER_GROUP_ID_KEY, "group-" + UUID.randomUUID()); attributes.put(ConsumerConstant.HEARTBEAT_INTERVAL_MS_KEY, String.valueOf(heartbeatIntervalMs)); + if (ownerId != null) { + attributes.put(ConsumerConstant.OWNER_ID_KEY, ownerId); + } + if (ownerEpoch != null) { + attributes.put(ConsumerConstant.OWNER_EPOCH_KEY, String.valueOf(ownerEpoch)); + } return new ConsumerConfig(attributes); } diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/conf/CommonConfig.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/conf/CommonConfig.java index 252d00b5a4fca..40d801f74e1f5 100644 --- a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/conf/CommonConfig.java +++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/conf/CommonConfig.java @@ -399,6 +399,12 @@ public class CommonConfig { private long subscriptionMetaSyncerInitialSyncDelayMinutes = 3; private long subscriptionMetaSyncerSyncIntervalMinutes = 3; + // Minimum allowed owner-lease-duration-ms accepted when creating/altering a topic owner. The + // lease is renewed by an independent ~5s heartbeat, so the duration must stay well above the + // heartbeat interval (times the tolerated misses plus propagation) to avoid falsely fencing a + // healthy owner; this floor enforces that invariant at admission time. Default: 1 minute. + private long subscriptionOwnerLeaseDurationMsMin = 60_000L; + private int subscriptionConsensusBatchMaxDelayInMs = 50; private long subscriptionConsensusBatchMaxSizeInBytes = 8 * MB; private int subscriptionConsensusBatchMaxTabletCount = 64; @@ -2759,6 +2765,14 @@ public void setSubscriptionMetaSyncerSyncIntervalMinutes( this.subscriptionMetaSyncerSyncIntervalMinutes = subscriptionMetaSyncerSyncIntervalMinutes; } + public long getSubscriptionOwnerLeaseDurationMsMin() { + return subscriptionOwnerLeaseDurationMsMin; + } + + public void setSubscriptionOwnerLeaseDurationMsMin(long subscriptionOwnerLeaseDurationMsMin) { + this.subscriptionOwnerLeaseDurationMsMin = subscriptionOwnerLeaseDurationMsMin; + } + public String getSchemaEngineMode() { return schemaEngineMode; } diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/conf/CommonDescriptor.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/conf/CommonDescriptor.java index fa853f61f47a8..964bc60b823d0 100644 --- a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/conf/CommonDescriptor.java +++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/conf/CommonDescriptor.java @@ -455,6 +455,12 @@ private void loadSubscriptionProps(TrimProperties properties) { "subscription_meta_syncer_sync_interval_minutes", String.valueOf(config.getSubscriptionMetaSyncerSyncIntervalMinutes())))); + config.setSubscriptionOwnerLeaseDurationMsMin( + Long.parseLong( + properties.getProperty( + "subscription_owner_lease_duration_ms_min", + String.valueOf(config.getSubscriptionOwnerLeaseDurationMsMin())))); + config.setSubscriptionConsensusBatchMaxDelayInMs( Integer.parseInt( properties.getProperty( diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/subscription/config/SubscriptionConfig.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/subscription/config/SubscriptionConfig.java index 2fad6cb88da7c..156397e7c1d46 100644 --- a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/subscription/config/SubscriptionConfig.java +++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/subscription/config/SubscriptionConfig.java @@ -142,6 +142,10 @@ public long getSubscriptionMetaSyncerSyncIntervalMinutes() { return COMMON_CONFIG.getSubscriptionMetaSyncerSyncIntervalMinutes(); } + public long getSubscriptionOwnerLeaseDurationMsMin() { + return COMMON_CONFIG.getSubscriptionOwnerLeaseDurationMsMin(); + } + // Consensus subscription batching parameters public int getSubscriptionConsensusBatchMaxDelayInMs() { return COMMON_CONFIG.getSubscriptionConsensusBatchMaxDelayInMs(); diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/subscription/meta/topic/TopicMeta.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/subscription/meta/topic/TopicMeta.java index badb77d6f486d..c44692f63588d 100644 --- a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/subscription/meta/topic/TopicMeta.java +++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/subscription/meta/topic/TopicMeta.java @@ -24,6 +24,8 @@ import org.apache.iotdb.commons.pipe.datastructure.visibility.VisibilityUtils; import org.apache.iotdb.commons.utils.TestOnly; import org.apache.iotdb.rpc.subscription.config.TopicConfig; +import org.apache.iotdb.rpc.subscription.config.TopicConstant; +import org.apache.iotdb.rpc.subscription.i18n.SubscriptionMessages; import org.apache.tsfile.utils.PublicBAOS; import org.apache.tsfile.utils.ReadWriteIOUtils; @@ -45,11 +47,24 @@ public class TopicMeta { private long creationTime; // unit in ms private TopicConfig config; + private String ownerId; + private long ownerEpoch; + // Highest owner epoch ever granted for this topic; retained across clearOwner. Mirrors the + // MAX_OWNER_EPOCH_KEY attribute (which carries it through serialization). Used to guarantee owner + // epoch is globally monotonic and never reused. + private long maxOwnerEpoch; + private long ownerLastTransferTimeMs; + private Long ownerLeaseDurationMs; + private Long ownerLeaseExpireTimeMs; + // TODO: remove this variable later private Set subscribedConsumerGroupIds; // unused now private TopicMeta() { this.config = new TopicConfig(new HashMap<>()); + this.ownerEpoch = -1L; + this.maxOwnerEpoch = -1L; + this.ownerLastTransferTimeMs = -1L; this.subscribedConsumerGroupIds = new HashSet<>(); } @@ -59,6 +74,10 @@ public TopicMeta( this.topicName = topicName; this.creationTime = creationTime; this.config = new TopicConfig(topicAttributes); + this.ownerEpoch = -1L; + this.maxOwnerEpoch = -1L; + this.ownerLastTransferTimeMs = -1L; + initOwnerFromTopicAttributes(topicAttributes); this.subscribedConsumerGroupIds = new HashSet<>(); } @@ -68,7 +87,29 @@ public TopicMeta deepCopy() { copied.topicName = topicName; copied.creationTime = creationTime; copied.config = new TopicConfig(new HashMap<>(config.getAttribute())); + copied.ownerId = ownerId; + copied.ownerEpoch = ownerEpoch; + copied.maxOwnerEpoch = maxOwnerEpoch; + copied.ownerLastTransferTimeMs = ownerLastTransferTimeMs; + copied.ownerLeaseDurationMs = ownerLeaseDurationMs; + copied.ownerLeaseExpireTimeMs = ownerLeaseExpireTimeMs; + + copied.subscribedConsumerGroupIds = new HashSet<>(subscribedConsumerGroupIds); + return copied; + } + + public TopicMeta deepCopyWithUpdatedAttributes(final Map updatedAttributes) { + final Map copiedAttributes = new HashMap<>(config.getAttribute()); + if (Objects.nonNull(updatedAttributes)) { + copiedAttributes.putAll(updatedAttributes); + if ((updatedAttributes.containsKey(TopicConstant.OWNER_ID_KEY) + || updatedAttributes.containsKey(TopicConstant.OWNER_EPOCH_KEY)) + && !updatedAttributes.containsKey(TopicConstant.OWNER_LEASE_DURATION_MS_KEY)) { + copiedAttributes.remove(TopicConstant.OWNER_LEASE_DURATION_MS_KEY); + } + } + final TopicMeta copied = new TopicMeta(topicName, creationTime, copiedAttributes); copied.subscribedConsumerGroupIds = new HashSet<>(subscribedConsumerGroupIds); return copied; } @@ -85,6 +126,125 @@ public TopicConfig getConfig() { return config; } + public boolean isOwnerFencingEnabled() { + return Objects.nonNull(ownerId) && ownerEpoch >= 0; + } + + public String getOwnerId() { + return ownerId; + } + + public long getOwnerEpoch() { + return ownerEpoch; + } + + public long getMaxOwnerEpoch() { + return maxOwnerEpoch; + } + + public long getOwnerLastTransferTimeMs() { + return ownerLastTransferTimeMs; + } + + public Long getOwnerLeaseExpireTimeMs() { + return ownerLeaseExpireTimeMs; + } + + public Long getOwnerLeaseDurationMs() { + return ownerLeaseDurationMs; + } + + public void transferOwner(final String ownerId, final long ownerEpoch) { + transferOwner(ownerId, ownerEpoch, null); + } + + public void transferOwner( + final String ownerId, final long ownerEpoch, final Long ownerLeaseDurationMs) { + if (Objects.isNull(ownerId) || ownerId.isEmpty()) { + throw new IllegalArgumentException(SubscriptionMessages.OWNER_ID_SHOULD_NOT_BE_EMPTY); + } + if (ownerEpoch < 0) { + throw new IllegalArgumentException(SubscriptionMessages.OWNER_EPOCH_SHOULD_NOT_BE_NEGATIVE); + } + if (isOwnerFencingEnabled() && ownerEpoch <= this.ownerEpoch) { + throw new IllegalArgumentException( + String.format( + SubscriptionMessages.OWNER_EPOCH_SHOULD_INCREASE_MONOTONICALLY, + this.ownerEpoch, + ownerEpoch)); + } + if (Objects.nonNull(ownerLeaseDurationMs) && ownerLeaseDurationMs <= 0) { + throw new IllegalArgumentException( + String.format( + SubscriptionMessages.OWNER_LEASE_DURATION_SHOULD_BE_POSITIVE, + topicName, + ownerLeaseDurationMs)); + } + + this.ownerId = ownerId; + this.ownerEpoch = ownerEpoch; + this.ownerLeaseDurationMs = ownerLeaseDurationMs; + // The lease expire time is DataNode-local runtime state derived from the owner-lease heartbeat; + // a freshly (re)assigned owner has no lease until the first heartbeat establishes one. + this.ownerLeaseExpireTimeMs = null; + this.ownerLastTransferTimeMs = System.currentTimeMillis(); + + config.getAttribute().put(TopicConstant.OWNER_ID_KEY, ownerId); + config.getAttribute().put(TopicConstant.OWNER_EPOCH_KEY, String.valueOf(ownerEpoch)); + if (ownerEpoch > maxOwnerEpoch) { + maxOwnerEpoch = ownerEpoch; + } + config.getAttribute().put(TopicConstant.MAX_OWNER_EPOCH_KEY, String.valueOf(maxOwnerEpoch)); + if (Objects.nonNull(ownerLeaseDurationMs)) { + config + .getAttribute() + .put(TopicConstant.OWNER_LEASE_DURATION_MS_KEY, String.valueOf(ownerLeaseDurationMs)); + } else { + config.getAttribute().remove(TopicConstant.OWNER_LEASE_DURATION_MS_KEY); + } + } + + public void clearOwner() { + ownerId = null; + ownerEpoch = -1L; + ownerLastTransferTimeMs = -1L; + ownerLeaseDurationMs = null; + ownerLeaseExpireTimeMs = null; + config.getAttribute().remove(TopicConstant.OWNER_ID_KEY); + config.getAttribute().remove(TopicConstant.OWNER_EPOCH_KEY); + config.getAttribute().remove(TopicConstant.OWNER_LEASE_DURATION_MS_KEY); + // Intentionally retain maxOwnerEpoch and MAX_OWNER_EPOCH_KEY so that a later re-enable cannot + // reuse an epoch <= any epoch ever granted (global monotonicity survives clear). + } + + public boolean matchesOwner(final String requestOwnerId, final Long requestOwnerEpoch) { + return !isOwnerFencingEnabled() + || (Objects.equals(ownerId, requestOwnerId) + && Objects.equals(ownerEpoch, requestOwnerEpoch) + && (Objects.isNull(ownerLeaseExpireTimeMs) + || System.currentTimeMillis() <= ownerLeaseExpireTimeMs)); + } + + /** + * DataNode side: apply a lease renewal pushed by ConfigNode via the dedicated subscription owner + * heartbeat. The pushed value is a relative remaining duration; it is converted to a local + * absolute expire time using this node's own clock, so no absolute timestamp is ever compared + * across ConfigNode and DataNode clocks. This expire time is purely DataNode-local runtime state + * (not a topic attribute, not serialized, not part of equality) so it never propagates back. Only + * refreshes when the pushed {@code (ownerId, ownerEpoch)} matches the current owner; stale or + * mismatched pushes are ignored (owner identity/epoch changes arrive via the topic-meta push + * path, not the lease heartbeat). + */ + public void applyOwnerLeaseFromHeartbeat( + final String ownerId, final long ownerEpoch, final long leaseRemainingMs) { + if (!isOwnerFencingEnabled() + || !Objects.equals(this.ownerId, ownerId) + || this.ownerEpoch != ownerEpoch) { + return; + } + this.ownerLeaseExpireTimeMs = System.currentTimeMillis() + leaseRemainingMs; + } + /** * @return true if the consumer group did not already subscribe this topic */ @@ -156,6 +316,8 @@ public static TopicMeta deserialize(final InputStream inputStream) throws IOExce topicMeta.subscribedConsumerGroupIds.add(ReadWriteIOUtils.readString(inputStream)); } + topicMeta.initOwnerFromTopicAttributes(topicMeta.config.getAttribute()); + return topicMeta; } @@ -177,9 +339,95 @@ public static TopicMeta deserialize(final ByteBuffer byteBuffer) { topicMeta.subscribedConsumerGroupIds.add(ReadWriteIOUtils.readString(byteBuffer)); } + topicMeta.initOwnerFromTopicAttributes(topicMeta.config.getAttribute()); + return topicMeta; } + public static void validateOwnerProgression( + final TopicMeta currentTopicMeta, final TopicMeta updatedTopicMeta) { + if (Objects.isNull(currentTopicMeta) || Objects.isNull(updatedTopicMeta)) { + return; + } + + // Global epoch monotonicity (never reused): a transfer to a different owner must use an epoch + // strictly greater than any epoch ever granted for this topic. The baseline (maxOwnerEpoch) is + // retained across clearOwner, so this also blocks epoch reuse after a clear -> re-enable cycle. + // Same-owner updates (e.g. attribute / lease changes) are unaffected. + if (updatedTopicMeta.isOwnerFencingEnabled() + && !Objects.equals(updatedTopicMeta.getOwnerId(), currentTopicMeta.getOwnerId()) + && updatedTopicMeta.getOwnerEpoch() <= currentTopicMeta.getMaxOwnerEpoch()) { + throw new IllegalArgumentException( + String.format( + SubscriptionMessages.OWNER_EPOCH_SHOULD_NEVER_BE_REUSED, + currentTopicMeta.getTopicName(), + currentTopicMeta.getMaxOwnerEpoch(), + updatedTopicMeta.getOwnerId(), + updatedTopicMeta.getOwnerEpoch())); + } + + if (!currentTopicMeta.isOwnerFencingEnabled()) { + return; + } + + if (!updatedTopicMeta.isOwnerFencingEnabled()) { + throw new IllegalArgumentException( + String.format( + SubscriptionMessages.OWNER_SHOULD_NOT_BE_CLEARED_BY_STALE_META, + currentTopicMeta.getTopicName(), + currentTopicMeta.getOwnerId(), + currentTopicMeta.getOwnerEpoch())); + } + + final boolean epochRollback = + updatedTopicMeta.getOwnerEpoch() < currentTopicMeta.getOwnerEpoch(); + final boolean sameEpochOwnerChanged = + updatedTopicMeta.getOwnerEpoch() == currentTopicMeta.getOwnerEpoch() + && !Objects.equals(updatedTopicMeta.getOwnerId(), currentTopicMeta.getOwnerId()); + if (epochRollback || sameEpochOwnerChanged) { + throw new IllegalArgumentException( + String.format( + SubscriptionMessages.OWNER_EPOCH_SHOULD_NOT_ROLL_BACK, + currentTopicMeta.getTopicName(), + currentTopicMeta.getOwnerId(), + currentTopicMeta.getOwnerEpoch(), + updatedTopicMeta.getOwnerId(), + updatedTopicMeta.getOwnerEpoch())); + } + } + + private void initOwnerFromTopicAttributes(final Map topicAttributes) { + final TopicConfig topicConfig = new TopicConfig(topicAttributes); + + // Restore the global epoch baseline first; it persists even when the owner has been cleared. + final Long configuredMaxOwnerEpoch = topicConfig.getLong(TopicConstant.MAX_OWNER_EPOCH_KEY); + if (Objects.nonNull(configuredMaxOwnerEpoch)) { + this.maxOwnerEpoch = configuredMaxOwnerEpoch; + } + + final String configuredOwnerId = topicConfig.getString(TopicConstant.OWNER_ID_KEY); + if (Objects.isNull(configuredOwnerId)) { + return; + } + + final Long configuredOwnerEpoch = topicConfig.getLong(TopicConstant.OWNER_EPOCH_KEY); + if (Objects.isNull(configuredOwnerEpoch)) { + throw new IllegalArgumentException( + String.format( + SubscriptionMessages.OWNER_EPOCH_SHOULD_BE_SET_WHEN_OWNER_ID_SET, + TopicConstant.OWNER_ID_KEY)); + } + final Long ownerLeaseDurationMs = + topicConfig.getLong(TopicConstant.OWNER_LEASE_DURATION_MS_KEY); + if (Objects.nonNull(ownerLeaseDurationMs) && ownerLeaseDurationMs <= 0) { + throw new IllegalArgumentException( + String.format( + SubscriptionMessages.OWNER_LEASE_DURATION_SHOULD_BE_POSITIVE_WHEN_SET, + TopicConstant.OWNER_LEASE_DURATION_MS_KEY)); + } + transferOwner(configuredOwnerId, configuredOwnerEpoch, ownerLeaseDurationMs); + } + /////////////////////////////// utilities /////////////////////////////// public Map generateExtractorAttributes(final String username) { @@ -257,12 +505,17 @@ public boolean equals(final Object obj) { final TopicMeta that = (TopicMeta) obj; return creationTime == that.creationTime && Objects.equals(topicName, that.topicName) - && Objects.equals(config, that.config); + && Objects.equals(config, that.config) + && Objects.equals(ownerId, that.ownerId) + && ownerEpoch == that.ownerEpoch + && maxOwnerEpoch == that.maxOwnerEpoch + && Objects.equals(ownerLeaseDurationMs, that.ownerLeaseDurationMs); } @Override public int hashCode() { - return Objects.hash(topicName, creationTime, config); + return Objects.hash( + topicName, creationTime, config, ownerId, ownerEpoch, maxOwnerEpoch, ownerLeaseDurationMs); } @Override @@ -274,6 +527,16 @@ public String toString() { + creationTime + ", config=" + config + + ", ownerId='" + + ownerId + + "', ownerEpoch=" + + ownerEpoch + + ", ownerLastTransferTimeMs=" + + ownerLastTransferTimeMs + + ", ownerLeaseDurationMs=" + + ownerLeaseDurationMs + + ", ownerLeaseExpireTimeMs=" + + ownerLeaseExpireTimeMs + '}'; } } diff --git a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/subscription/topic/TopicDeSerTest.java b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/subscription/topic/TopicDeSerTest.java index d9c280e14938c..fa47c7241b521 100644 --- a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/subscription/topic/TopicDeSerTest.java +++ b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/subscription/topic/TopicDeSerTest.java @@ -20,10 +20,13 @@ package org.apache.iotdb.commons.subscription.topic; import org.apache.iotdb.commons.subscription.meta.topic.TopicMeta; +import org.apache.iotdb.rpc.subscription.config.TopicConstant; import org.junit.Assert; import org.junit.Test; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.util.HashMap; @@ -55,6 +58,166 @@ public void test() throws IOException { topicMeta.getSubscribedConsumerGroupIds(), topicMeta2.getSubscribedConsumerGroupIds()); } + @Test + public void testTopicOwnerDeSer() throws IOException { + Map topicAttributes = new HashMap<>(); + topicAttributes.put(TopicConstant.OWNER_ID_KEY, "owner1"); + topicAttributes.put(TopicConstant.OWNER_EPOCH_KEY, "5"); + + TopicMeta topicMeta = new TopicMeta("test_topic", 1, topicAttributes); + + Assert.assertTrue(topicMeta.isOwnerFencingEnabled()); + Assert.assertEquals("owner1", topicMeta.getOwnerId()); + Assert.assertEquals(5L, topicMeta.getOwnerEpoch()); + Assert.assertTrue(topicMeta.matchesOwner("owner1", 5L)); + Assert.assertFalse(topicMeta.matchesOwner("owner2", 5L)); + Assert.assertFalse(topicMeta.matchesOwner("owner1", 4L)); + + TopicMeta topicMeta1 = TopicMeta.deserialize(topicMeta.serialize()); + TopicMeta topicMeta2 = topicMeta1.deepCopy(); + + Assert.assertEquals(topicMeta, topicMeta1); + Assert.assertEquals(topicMeta, topicMeta2); + Assert.assertEquals(topicMeta.getOwnerId(), topicMeta2.getOwnerId()); + Assert.assertEquals(topicMeta.getOwnerEpoch(), topicMeta2.getOwnerEpoch()); + Assert.assertEquals(topicMeta.getOwnerLeaseDurationMs(), topicMeta2.getOwnerLeaseDurationMs()); + Assert.assertEquals( + topicMeta.getOwnerLeaseExpireTimeMs(), topicMeta2.getOwnerLeaseExpireTimeMs()); + + topicMeta.transferOwner("owner2", 6L, 100L); + Assert.assertEquals("owner2", topicMeta.getOwnerId()); + Assert.assertEquals(6L, topicMeta.getOwnerEpoch()); + Assert.assertEquals("owner2", topicMeta.getConfig().getString(TopicConstant.OWNER_ID_KEY)); + Assert.assertEquals( + 6L, topicMeta.getConfig().getLong(TopicConstant.OWNER_EPOCH_KEY).longValue()); + Assert.assertEquals( + 100L, topicMeta.getConfig().getLong(TopicConstant.OWNER_LEASE_DURATION_MS_KEY).longValue()); + Assert.assertEquals(100L, topicMeta.getOwnerLeaseDurationMs().longValue()); + // A freshly transferred owner has no lease expiry until the first owner-lease heartbeat. + Assert.assertNull(topicMeta.getOwnerLeaseExpireTimeMs()); + + topicMeta.clearOwner(); + Assert.assertFalse(topicMeta.isOwnerFencingEnabled()); + Assert.assertFalse(topicMeta.getConfig().hasAttribute(TopicConstant.OWNER_ID_KEY)); + Assert.assertFalse(topicMeta.getConfig().hasAttribute(TopicConstant.OWNER_EPOCH_KEY)); + Assert.assertFalse( + topicMeta.getConfig().hasAttribute(TopicConstant.OWNER_LEASE_DURATION_MS_KEY)); + } + + @Test + public void testDeepCopyWithUpdatedAttributesTransfersOwnerAndPreservesExistingConfig() { + final Map topicAttributes = new HashMap<>(); + topicAttributes.put(TopicConstant.PATH_KEY, "root.sg.**"); + topicAttributes.put(TopicConstant.OWNER_ID_KEY, "owner1"); + topicAttributes.put(TopicConstant.OWNER_EPOCH_KEY, "5"); + final TopicMeta topicMeta = new TopicMeta("test_topic", 1, topicAttributes); + topicMeta.addSubscribedConsumerGroup("group1"); + + final Map updatedAttributes = new HashMap<>(); + updatedAttributes.put(TopicConstant.OWNER_ID_KEY, "owner2"); + updatedAttributes.put(TopicConstant.OWNER_EPOCH_KEY, "6"); + final TopicMeta updatedTopicMeta = topicMeta.deepCopyWithUpdatedAttributes(updatedAttributes); + + Assert.assertEquals( + "root.sg.**", updatedTopicMeta.getConfig().getString(TopicConstant.PATH_KEY)); + Assert.assertEquals("owner2", updatedTopicMeta.getOwnerId()); + Assert.assertEquals(6L, updatedTopicMeta.getOwnerEpoch()); + Assert.assertTrue(updatedTopicMeta.isSubscribedByConsumerGroup("group1")); + Assert.assertEquals("owner1", topicMeta.getOwnerId()); + Assert.assertEquals(5L, topicMeta.getOwnerEpoch()); + } + + @Test + public void testDeepCopyWithUpdatedAttributesTransfersOwnerAndClearsExistingLease() { + final Map topicAttributes = new HashMap<>(); + topicAttributes.put(TopicConstant.OWNER_ID_KEY, "owner1"); + topicAttributes.put(TopicConstant.OWNER_EPOCH_KEY, "5"); + topicAttributes.put(TopicConstant.OWNER_LEASE_DURATION_MS_KEY, "60000"); + final TopicMeta topicMeta = new TopicMeta("test_topic", 1, topicAttributes); + + final Map updatedAttributes = new HashMap<>(); + updatedAttributes.put(TopicConstant.OWNER_ID_KEY, "owner2"); + updatedAttributes.put(TopicConstant.OWNER_EPOCH_KEY, "6"); + final TopicMeta updatedTopicMeta = topicMeta.deepCopyWithUpdatedAttributes(updatedAttributes); + + Assert.assertEquals("owner2", updatedTopicMeta.getOwnerId()); + Assert.assertEquals(6L, updatedTopicMeta.getOwnerEpoch()); + Assert.assertNull(updatedTopicMeta.getOwnerLeaseDurationMs()); + Assert.assertNull(updatedTopicMeta.getOwnerLeaseExpireTimeMs()); + Assert.assertFalse( + updatedTopicMeta.getConfig().hasAttribute(TopicConstant.OWNER_LEASE_DURATION_MS_KEY)); + } + + @Test + public void testOwnerLeaseExpireIsDataNodeLocalRuntimeOnly() throws IOException { + final Map topicAttributes = new HashMap<>(); + topicAttributes.put(TopicConstant.OWNER_ID_KEY, "owner1"); + topicAttributes.put(TopicConstant.OWNER_EPOCH_KEY, "5"); + topicAttributes.put(TopicConstant.OWNER_LEASE_DURATION_MS_KEY, "60000"); + final TopicMeta topicMeta = new TopicMeta("test_topic", 1, topicAttributes); + + // Construction (ConfigNode side) never derives an absolute expiry; that is established only by + // the DataNode-local owner-lease heartbeat. + Assert.assertNull(topicMeta.getOwnerLeaseExpireTimeMs()); + Assert.assertTrue(topicMeta.matchesOwner("owner1", 5L)); + + // Apply a heartbeat with a relative remaining duration; expiry is now set locally and the owner + // still matches before the lease drains. + topicMeta.applyOwnerLeaseFromHeartbeat("owner1", 5L, 60000L); + Assert.assertNotNull(topicMeta.getOwnerLeaseExpireTimeMs()); + Assert.assertTrue(topicMeta.matchesOwner("owner1", 5L)); + + // The locally-derived expiry is runtime-only: it is not a topic attribute, does not affect + // equality, and does not survive serialization. + final TopicMeta roundTrip = TopicMeta.deserialize(topicMeta.serialize()); + Assert.assertNull(roundTrip.getOwnerLeaseExpireTimeMs()); + Assert.assertEquals(topicMeta, roundTrip); + + // A stale/mismatched heartbeat is ignored. + topicMeta.applyOwnerLeaseFromHeartbeat("owner2", 5L, 60000L); + topicMeta.applyOwnerLeaseFromHeartbeat("owner1", 4L, 60000L); + Assert.assertTrue(topicMeta.matchesOwner("owner1", 5L)); + + // An elapsed lease causes the current owner to stop matching (DataNode fencing). + topicMeta.applyOwnerLeaseFromHeartbeat("owner1", 5L, -1L); + Assert.assertFalse(topicMeta.matchesOwner("owner1", 5L)); + } + + @Test + public void testOwnerLeaseDurationGeneratesExpireTime() { + final long ownerLeaseDurationMs = 60000; + final Map topicAttributes = new HashMap<>(); + topicAttributes.put(TopicConstant.OWNER_ID_KEY, "owner1"); + topicAttributes.put(TopicConstant.OWNER_EPOCH_KEY, "5"); + topicAttributes.put( + TopicConstant.OWNER_LEASE_DURATION_MS_KEY, String.valueOf(ownerLeaseDurationMs)); + + final TopicMeta topicMeta = new TopicMeta("test_topic", 1, topicAttributes); + + // The lease duration is persisted, but no absolute expiry is derived at construction time: the + // expiry is established only by the DataNode-local owner-lease heartbeat. + Assert.assertEquals(ownerLeaseDurationMs, topicMeta.getOwnerLeaseDurationMs().longValue()); + Assert.assertEquals( + ownerLeaseDurationMs, + topicMeta.getConfig().getLong(TopicConstant.OWNER_LEASE_DURATION_MS_KEY).longValue()); + Assert.assertNull(topicMeta.getOwnerLeaseExpireTimeMs()); + } + + @Test + public void testSequentialTopicMetaDeserializeDoesNotConsumeNextTopic() throws IOException { + final TopicMeta firstTopicMeta = new TopicMeta("first_topic", 1, new HashMap<>()); + final TopicMeta secondTopicMeta = new TopicMeta("second_topic", 2, new HashMap<>()); + + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + firstTopicMeta.serialize(outputStream); + secondTopicMeta.serialize(outputStream); + + final ByteArrayInputStream inputStream = new ByteArrayInputStream(outputStream.toByteArray()); + Assert.assertEquals(firstTopicMeta, TopicMeta.deserialize(inputStream)); + Assert.assertEquals(secondTopicMeta, TopicMeta.deserialize(inputStream)); + Assert.assertEquals(0, inputStream.available()); + } + @Test public void testGenerateExtractorAttributesWithEncryptedPassword() { final TopicMeta topicMeta = new TopicMeta("test_topic", 1, new HashMap<>()); @@ -65,4 +228,41 @@ public void testGenerateExtractorAttributesWithEncryptedPassword() { Assert.assertEquals("test_user", extractorAttributes.get("username")); Assert.assertEquals("encrypted-password", extractorAttributes.get("password")); } + + @Test + public void testOwnerEpochNeverReusedAcrossClearAndSerialization() throws IOException { + final Map topicAttributes = new HashMap<>(); + topicAttributes.put(TopicConstant.OWNER_ID_KEY, "owner1"); + topicAttributes.put(TopicConstant.OWNER_EPOCH_KEY, "6"); + final TopicMeta topicMeta = new TopicMeta("test_topic", 1, topicAttributes); + Assert.assertEquals(6L, topicMeta.getMaxOwnerEpoch()); + + // Clearing the owner retains the global epoch baseline. + topicMeta.clearOwner(); + Assert.assertFalse(topicMeta.isOwnerFencingEnabled()); + Assert.assertEquals(6L, topicMeta.getMaxOwnerEpoch()); + + // The baseline survives serialization even though the owner is cleared. + final TopicMeta deserialized = TopicMeta.deserialize(topicMeta.serialize()); + Assert.assertFalse(deserialized.isOwnerFencingEnabled()); + Assert.assertEquals(6L, deserialized.getMaxOwnerEpoch()); + + // Re-enabling with an epoch <= the historical max (reuse) is rejected. + final Map reuse = new HashMap<>(); + reuse.put(TopicConstant.OWNER_ID_KEY, "owner2"); + reuse.put(TopicConstant.OWNER_EPOCH_KEY, "6"); + final TopicMeta reuseMeta = deserialized.deepCopyWithUpdatedAttributes(reuse); + Assert.assertThrows( + IllegalArgumentException.class, + () -> TopicMeta.validateOwnerProgression(deserialized, reuseMeta)); + + // Re-enabling with an epoch strictly greater than the historical max is allowed. + final Map next = new HashMap<>(); + next.put(TopicConstant.OWNER_ID_KEY, "owner2"); + next.put(TopicConstant.OWNER_EPOCH_KEY, "7"); + final TopicMeta nextMeta = deserialized.deepCopyWithUpdatedAttributes(next); + TopicMeta.validateOwnerProgression(deserialized, nextMeta); + Assert.assertEquals(7L, nextMeta.getOwnerEpoch()); + Assert.assertEquals(7L, nextMeta.getMaxOwnerEpoch()); + } } diff --git a/iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/grammar/sql/RelationalSql.g4 b/iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/grammar/sql/RelationalSql.g4 index 93dfa8bb1f4f1..55f965ba4abc9 100644 --- a/iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/grammar/sql/RelationalSql.g4 +++ b/iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/grammar/sql/RelationalSql.g4 @@ -108,6 +108,7 @@ statement // Subscription Statement | createTopicStatement + | alterTopicStatement | dropTopicStatement | showTopicsStatement | showSubscriptionsStatement @@ -537,6 +538,10 @@ createTopicStatement : CREATE TOPIC (IF NOT EXISTS)? topicName=identifier topicAttributesClause? ; +alterTopicStatement + : ALTER TOPIC topicName=identifier topicAttributesClause + ; + topicAttributesClause : WITH '(' topicAttributeClause (',' topicAttributeClause)* ')' ; diff --git a/iotdb-protocol/thrift-confignode/src/main/thrift/confignode.thrift b/iotdb-protocol/thrift-confignode/src/main/thrift/confignode.thrift index 26600f4ea9b47..b0b20ee4ac701 100644 --- a/iotdb-protocol/thrift-confignode/src/main/thrift/confignode.thrift +++ b/iotdb-protocol/thrift-confignode/src/main/thrift/confignode.thrift @@ -1942,6 +1942,9 @@ service IConfigNodeRPCService { /** Create Topic */ common.TSStatus createTopic(TCreateTopicReq req) + /** Alter Topic */ + common.TSStatus alterTopic(TAlterTopicReq req) + /** Drop Topic */ common.TSStatus dropTopic(string topicName) diff --git a/iotdb-protocol/thrift-datanode/src/main/thrift/datanode.thrift b/iotdb-protocol/thrift-datanode/src/main/thrift/datanode.thrift index 2f3703ec5fcfd..11de2e4cba232 100644 --- a/iotdb-protocol/thrift-datanode/src/main/thrift/datanode.thrift +++ b/iotdb-protocol/thrift-datanode/src/main/thrift/datanode.thrift @@ -582,6 +582,21 @@ struct TPushTopicMetaRespExceptionMessage { 3: required i64 timeStamp } +// Dedicated subscription owner-lease heartbeat (CN -> DN), decoupled from the node heartbeat. +// Carries a relative remaining duration; the DataNode derives the local expire time on its own +// clock (leaseExpireAt = localNow + leaseRemainingMs), so no absolute timestamp is compared across +// ConfigNode and DataNode clocks. +struct TTopicOwnerLeaseEntry { + 1: required string topicName + 2: required string ownerId + 3: required i64 ownerEpoch + 4: required i64 leaseRemainingMs +} + +struct TPushTopicOwnerLeaseReq { + 1: required list ownerLeases +} + struct TPushConsumerGroupMetaReq { 1: required list consumerGroupMetas } @@ -1233,6 +1248,12 @@ service IDataNodeRPCService { */ TPushTopicMetaResp pushMultiTopicMeta(TPushMultiTopicMetaReq req) + /** + * Renew topic owner leases on DataNodes via the dedicated subscription owner heartbeat + * (independent from the node heartbeat). Carries relative remaining durations. + */ + common.TSStatus pushTopicOwnerLease(TPushTopicOwnerLeaseReq req) + /** * Send consumerGroupMetas to DataNodes, for synchronization */