[Feature] Support TAG value filtering for table subscriptions (#18639)

* Subscription: support table-model tag filtering

* Subscription: expose tag filter diagnostics in SHOW TOPICS

* Address review feedback for table subscription TAG filters

* Preserve quoted TAG filters in topic SQL formatting
diff --git a/integration-test/src/test/java/org/apache/iotdb/subscription/it/consensus/local/tablemodel/ConsensusSubscriptionTableITSupport.java b/integration-test/src/test/java/org/apache/iotdb/subscription/it/consensus/local/tablemodel/ConsensusSubscriptionTableITSupport.java
index 29a859d..6f4e828 100644
--- a/integration-test/src/test/java/org/apache/iotdb/subscription/it/consensus/local/tablemodel/ConsensusSubscriptionTableITSupport.java
+++ b/integration-test/src/test/java/org/apache/iotdb/subscription/it/consensus/local/tablemodel/ConsensusSubscriptionTableITSupport.java
@@ -125,6 +125,16 @@
       final String tablePattern,
       final String columnFilter)
       throws Exception {
+    createConsensusTopic(topicName, databasePattern, tablePattern, columnFilter, null);
+  }
+
+  static void createConsensusTopic(
+      final String topicName,
+      final String databasePattern,
+      final String tablePattern,
+      final String columnFilter,
+      final String tagFilter)
+      throws Exception {
     final String host = EnvFactory.getEnv().getIP();
     final int port = Integer.parseInt(EnvFactory.getEnv().getPort());
 
@@ -141,6 +151,9 @@
       if (columnFilter != null) {
         config.put(TopicConstant.COLUMN_FILTER_KEY, columnFilter);
       }
+      if (tagFilter != null) {
+        config.put(TopicConstant.TAG_FILTER_KEY, tagFilter);
+      }
       session.createTopic(topicName, config);
     }
   }
@@ -160,6 +173,21 @@
     }
   }
 
+  static void alterConsensusTopicTagFilter(final String topicName, final String tagFilter)
+      throws Exception {
+    final String host = EnvFactory.getEnv().getIP();
+    final int port = Integer.parseInt(EnvFactory.getEnv().getPort());
+
+    try (final ISubscriptionTableSession session =
+        new SubscriptionTableSessionBuilder().host(host).port(port).build()) {
+      session.open();
+
+      final Properties config = new Properties();
+      config.put(TopicConstant.TAG_FILTER_KEY, tagFilter);
+      session.alterTopic(topicName, config);
+    }
+  }
+
   static SubscriptionTablePullConsumer createConsumer(
       final String consumerId, final String consumerGroupId) throws Exception {
     final SubscriptionTablePullConsumer consumer =
diff --git a/integration-test/src/test/java/org/apache/iotdb/subscription/it/consensus/local/tablemodel/IoTDBConsensusSubscriptionFilterTableIT.java b/integration-test/src/test/java/org/apache/iotdb/subscription/it/consensus/local/tablemodel/IoTDBConsensusSubscriptionFilterTableIT.java
index 96193a0..f311d00 100644
--- a/integration-test/src/test/java/org/apache/iotdb/subscription/it/consensus/local/tablemodel/IoTDBConsensusSubscriptionFilterTableIT.java
+++ b/integration-test/src/test/java/org/apache/iotdb/subscription/it/consensus/local/tablemodel/IoTDBConsensusSubscriptionFilterTableIT.java
@@ -283,6 +283,80 @@
   }
 
   @Test
+  public void testTagFilteringAndAlterInConsensusQueue() throws Exception {
+    final ConsensusSubscriptionTableITSupport.TestIdentifiers ids =
+        ConsensusSubscriptionTableITSupport.newIdentifiers("table_filter_tags");
+    final String database = ids.getDatabase();
+    final String table = "t1";
+    final String schema = "tag1 STRING TAG, s1 INT64 FIELD, s2 DOUBLE FIELD";
+    final String expectedColumnSignature =
+        ConsensusSubscriptionTableITSupport.normalizeColumnSignature("tag1", "s2");
+    SubscriptionTablePullConsumer consumer = null;
+
+    try {
+      ConsensusSubscriptionTableITSupport.createDatabaseAndTable(database, table, schema);
+      try (final ITableSession session = EnvFactory.getEnv().getTableSessionConnection()) {
+        session.executeNonQueryStatement("use " + database);
+        session.executeNonQueryStatement(
+            "insert into " + table + "(tag1, s1, s2, time) values ('bootstrap', 0, 0.0, 0)");
+        session.executeNonQueryStatement("flush");
+      }
+
+      ConsensusSubscriptionTableITSupport.createConsensusTopic(
+          ids.getTopic(), database, table, "column_name = \"s2\"", "tag1 = \"keep\"");
+
+      consumer =
+          ConsensusSubscriptionTableITSupport.createConsumer(
+              ids.getConsumerId(), ids.getConsumerGroupId());
+      consumer.subscribe(ids.getTopic());
+
+      final Set<String> expectedBeforeAlter = new LinkedHashSet<>();
+      try (final ITableSession session = EnvFactory.getEnv().getTableSessionConnection()) {
+        session.executeNonQueryStatement("use " + database);
+        for (int i = 1; i <= 5; i++) {
+          final long timestamp = 100L + i;
+          final String tag = i % 2 == 1 ? "keep" : "drop";
+          session.executeNonQueryStatement(
+              String.format(
+                  Locale.ROOT,
+                  "insert into %s(tag1, s1, s2, time) values ('%s', %d, %.1f, %d)",
+                  table,
+                  tag,
+                  i * 10L,
+                  i + 0.5d,
+                  timestamp));
+          if ("keep".equals(tag)) {
+            expectedBeforeAlter.add(
+                ConsensusSubscriptionTableITSupport.rowKey(database, table, timestamp));
+          }
+        }
+        session.executeNonQueryStatement("flush");
+      }
+
+      final ConsensusSubscriptionTableITSupport.ConsumedRecords consumedBeforeAlter =
+          ConsensusSubscriptionTableITSupport.pollAndCommitUntilContains(
+              consumer, expectedBeforeAlter, 50);
+      ConsensusSubscriptionTableITSupport.assertExactRowKeys(
+          expectedBeforeAlter, consumedBeforeAlter);
+      Assert.assertEquals(
+          Collections.singleton(expectedColumnSignature),
+          consumedBeforeAlter.getSeenColumnSignatures());
+
+      ConsensusSubscriptionTableITSupport.alterConsensusTopicTagFilter(
+          ids.getTopic(), "tag1 LIKE \"t1_tag_%\"");
+      final ConsensusSubscriptionTableITSupport.ConsumedRecords consumedAfterAlter =
+          ConsensusSubscriptionTableITSupport.insertRowsAndPollUntilColumnSignature(
+              consumer, database, table, 200L, 3, true, false, expectedColumnSignature, 60);
+      Assert.assertTrue(consumedAfterAlter.getRowCount() > 0);
+      Assert.assertTrue(
+          consumedAfterAlter.getSeenColumnSignatures().contains(expectedColumnSignature));
+      ConsensusSubscriptionTableITSupport.assertNoMoreMessages(consumer, 3, Duration.ofMillis(500));
+    } finally {
+      ConsensusSubscriptionTableITSupport.cleanup(consumer, ids.getTopic(), database);
+    }
+  }
+
+  @Test
   public void testColumnFilteringComplexOperatorsInConsensusQueue() throws Exception {
     final ConsensusSubscriptionTableITSupport.TestIdentifiers ids =
         ConsensusSubscriptionTableITSupport.newIdentifiers("table_filter_complex_operators");
diff --git a/integration-test/src/test/java/org/apache/iotdb/subscription/it/dual/tablemodel/IoTDBSubscriptionColumnFilterIT.java b/integration-test/src/test/java/org/apache/iotdb/subscription/it/dual/tablemodel/IoTDBSubscriptionColumnFilterIT.java
index d300e2c..2e4fc5f 100644
--- a/integration-test/src/test/java/org/apache/iotdb/subscription/it/dual/tablemodel/IoTDBSubscriptionColumnFilterIT.java
+++ b/integration-test/src/test/java/org/apache/iotdb/subscription/it/dual/tablemodel/IoTDBSubscriptionColumnFilterIT.java
@@ -452,6 +452,73 @@
   }
 
   @Test
+  public void testLiveRecordHandlerTagFilterBeforeColumnFilter() throws Exception {
+    final String database = databaseName("tag_record");
+    final String topicName = topicName("tag_record");
+    final String consumerId = consumerName("tag_record");
+    final String consumerGroupId = consumerGroupName("tag_record");
+
+    try {
+      createDatabaseAndTable(senderEnv, database, TABLE_NAME, TABLE_SCHEMA);
+      createTopicWithFilters(
+          topicName,
+          database,
+          TABLE_NAME,
+          TopicConstant.MODE_INITIAL_VALUE,
+          TopicConstant.FORMAT_RECORD_HANDLER_VALUE,
+          COLUMN_FILTER,
+          "tag1 IN (\"tag_1\", \"tag_3\")");
+
+      try (final ISubscriptionTablePullConsumer consumer =
+          createConsumer(consumerId, consumerGroupId)) {
+        consumer.subscribe(topicName);
+        insertRows(senderEnv, database, TABLE_NAME, 1, 5);
+
+        final Set<Long> expectedTimestamps = new LinkedHashSet<>(Arrays.asList(1L, 3L));
+        final ConsumedRecordStats stats =
+            pollRecordMessagesForTimestamps(consumer, expectedTimestamps, true);
+
+        Assert.assertEquals(expectedTimestamps, stats.timestamps);
+        Assert.assertEquals(EXPECTED_COLUMNS, stats.columnNames);
+      }
+    } finally {
+      cleanup(topicName, database);
+    }
+  }
+
+  @Test
+  public void testSnapshotTsFileTagFilter() throws Exception {
+    final String database = databaseName("tag_snapshot_tsfile");
+    final String topicName = topicName("tag_snapshot_tsfile");
+    final String consumerId = consumerName("tag_snapshot_tsfile");
+    final String consumerGroupId = consumerGroupName("tag_snapshot_tsfile");
+
+    try {
+      createDatabaseAndTable(senderEnv, database, TABLE_NAME, TABLE_SCHEMA);
+      createDatabaseAndTable(receiverEnv, database, TABLE_NAME, TABLE_SCHEMA);
+      insertRows(senderEnv, database, TABLE_NAME, 10, 14);
+      createTopicWithFilters(
+          topicName,
+          database,
+          TABLE_NAME,
+          TopicConstant.MODE_SNAPSHOT_VALUE,
+          TopicConstant.FORMAT_TS_FILE_VALUE,
+          TopicConstant.COLUMN_FILTER_DEFAULT_VALUE,
+          "tag1 IN (\"tag_10\", \"tag_12\")");
+
+      try (final ISubscriptionTablePullConsumer consumer =
+          createConsumer(consumerId, consumerGroupId)) {
+        consumer.subscribe(topicName);
+        pollTsFileMessagesAndLoad(consumer, database, 2);
+      }
+
+      assertLoadedTimestamps(database, new LinkedHashSet<>(Arrays.asList(10L, 12L)));
+    } finally {
+      cleanup(topicName, database);
+    }
+  }
+
+  @Test
   public void testSnapshotTsFileColumnFilter() throws Exception {
     final String database = databaseName("snapshot_tsfile");
     final String topicName = topicName("snapshot_tsfile");
@@ -1102,6 +1169,34 @@
         topicName, database, tableName, TopicConstant.MODE_INITIAL_VALUE, format, "", false);
   }
 
+  private void createTopicWithFilters(
+      final String topicName,
+      final String database,
+      final String tableName,
+      final String mode,
+      final String format,
+      final String columnFilter,
+      final String tagFilter)
+      throws Exception {
+    try (final ISubscriptionTableSession session =
+        new SubscriptionTableSessionBuilder()
+            .host(senderEnv.getIP())
+            .port(Integer.parseInt(senderEnv.getPort()))
+            .build()) {
+      session.open();
+      session.dropTopicIfExists(topicName);
+
+      final Properties config = new Properties();
+      config.put(TopicConstant.MODE_KEY, mode);
+      config.put(TopicConstant.FORMAT_KEY, format);
+      config.put(TopicConstant.DATABASE_KEY, database);
+      config.put(TopicConstant.TABLE_KEY, tableName);
+      config.put(TopicConstant.COLUMN_FILTER_KEY, columnFilter);
+      config.put(TopicConstant.TAG_FILTER_KEY, tagFilter);
+      session.createTopic(topicName, config);
+    }
+  }
+
   private void createTopic(
       final String topicName,
       final String database,
@@ -1547,6 +1642,22 @@
     }
   }
 
+  private void assertLoadedTimestamps(final String database, final Set<Long> expectedTimestamps)
+      throws Exception {
+    final Set<Long> actualTimestamps = new LinkedHashSet<>();
+    try (final Connection connection = receiverEnv.getConnection(BaseEnv.TABLE_SQL_DIALECT);
+        final Statement statement = connection.createStatement()) {
+      statement.execute("use " + database);
+      try (final java.sql.ResultSet resultSet =
+          statement.executeQuery("select time from " + TABLE_NAME + " order by time")) {
+        while (resultSet.next()) {
+          actualTimestamps.add(resultSet.getLong("time"));
+        }
+      }
+    }
+    Assert.assertEquals(expectedTimestamps, actualTimestamps);
+  }
+
   private void cleanup(final String topicName, final String database) {
     try (final ISubscriptionTableSession session =
         new SubscriptionTableSessionBuilder()
diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicConfig.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicConfig.java
index bc4a545..3980f09 100644
--- a/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicConfig.java
+++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicConfig.java
@@ -28,16 +28,23 @@
 import java.io.IOException;
 import java.nio.ByteBuffer;
 import java.util.Collections;
+import java.util.EnumMap;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicLong;
 import java.util.stream.Collectors;
 
 public class TopicConfig extends PipeParameters {
 
+  private static final AtomicLong NEXT_FILTER_VERSION = new AtomicLong();
+
+  // DataNode-local revision. It is not part of the serialized topic attributes.
+  private long filterVersion = NEXT_FILTER_VERSION.incrementAndGet();
+
   private static final Set<String> MODE_VALUE_SET;
   private static final Set<String> ORDER_MODE_VALUE_SET;
 
@@ -63,6 +70,37 @@
     super(attributes);
   }
 
+  public TopicConfig(final TopicConfig config) {
+    super(new HashMap<>(config.getAttribute()));
+    filterVersion = config.filterVersion;
+  }
+
+  public long getFilterVersion() {
+    return filterVersion;
+  }
+
+  /** Called before publishing replacement topic metadata on a DataNode. */
+  public void inheritFilterVersion(final TopicConfig previousConfig) {
+    if (previousConfig != null) {
+      filterVersion =
+          getFilteringAttributes().equals(previousConfig.getFilteringAttributes())
+              ? previousConfig.filterVersion
+              : NEXT_FILTER_VERSION.incrementAndGet();
+    }
+  }
+
+  private Map<TopicProperty, String> getFilteringAttributes() {
+    final Map<TopicProperty, String> result = new EnumMap<>(TopicProperty.class);
+    attributes.forEach(
+        (key, value) -> {
+          final TopicProperty property = TopicProperty.fromKey(key);
+          if (property != null && property.isFilteringProperty()) {
+            result.put(property, value);
+          }
+        });
+    return result;
+  }
+
   // TODO: hide from the client
   // refer to org.apache.iotdb.commons.pipe.config.constant.SystemConstant
   private static final String SQL_DIALECT_KEY = "__system.sql-dialect";
@@ -254,6 +292,23 @@
     return TopicConstant.COLUMN_FILTER_DEFAULT_VALUE.equalsIgnoreCase(getColumnFilter().trim());
   }
 
+  public Map<String, String> getAttributesWithSourceTagFilter() {
+    return Collections.singletonMap(TopicConstant.TAG_FILTER_KEY, getTagFilter());
+  }
+
+  public String getTagFilter() {
+    return getStringIgnoreCase(
+        TopicConstant.TAG_FILTER_KEY, TopicConstant.TAG_FILTER_DEFAULT_VALUE);
+  }
+
+  public boolean hasTagFilter() {
+    return containsKeyIgnoreCase(TopicConstant.TAG_FILTER_KEY);
+  }
+
+  public boolean isTagFilterTrivial() {
+    return TopicConstant.TAG_FILTER_DEFAULT_VALUE.equalsIgnoreCase(getTagFilter().trim());
+  }
+
   public Map<String, String> getAttributesWithSourceTimeRange() {
     final Map<String, String> attributesWithTimeRange = new HashMap<>();
 
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 8a8af71..f6f3b59 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
@@ -31,6 +31,7 @@
   public static final String DATABASE_KEY = "database";
   public static final String TABLE_KEY = "table";
   public static final String COLUMN_FILTER_KEY = "column-filter";
+  public static final String TAG_FILTER_KEY = "tag-filter";
   public static final String RETENTION_BYTES_KEY = "retention.bytes";
   public static final String RETENTION_MS_KEY = "retention.ms";
   public static final String RETAIN_PROGRESS_AFTER_UNSUBSCRIBE_KEY =
@@ -39,6 +40,7 @@
   public static final String DATABASE_DEFAULT_VALUE = ".*";
   public static final String TABLE_DEFAULT_VALUE = ".*";
   public static final String COLUMN_FILTER_DEFAULT_VALUE = "true";
+  public static final String TAG_FILTER_DEFAULT_VALUE = "true";
 
   public static final String START_TIME_KEY = "start-time";
   public static final String END_TIME_KEY = "end-time";
diff --git a/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicProperty.java b/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicProperty.java
new file mode 100644
index 0000000..c5b5915
--- /dev/null
+++ b/iotdb-client/subscription/src/main/java/org/apache/iotdb/rpc/subscription/config/TopicProperty.java
@@ -0,0 +1,90 @@
+/*
+ * 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.config;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+/** Built-in topic keys and the properties that affect a batch's filtering configuration. */
+public enum TopicProperty {
+  PATH(TopicConstant.PATH_KEY, true),
+  PATTERN(TopicConstant.PATTERN_KEY, true),
+  DATABASE(TopicConstant.DATABASE_KEY, true),
+  TABLE(TopicConstant.TABLE_KEY, true),
+  COLUMN_FILTER(TopicConstant.COLUMN_FILTER_KEY, true),
+  TAG_FILTER(TopicConstant.TAG_FILTER_KEY, true),
+  START_TIME(TopicConstant.START_TIME_KEY, true),
+  END_TIME(TopicConstant.END_TIME_KEY, true),
+  LOOSE_RANGE(TopicConstant.LOOSE_RANGE_KEY, true),
+  STRICT(TopicConstant.STRICT_KEY, true),
+  SQL_DIALECT("__system.sql-dialect", true),
+  SOURCE_CAPTURE_TREE("source.capture.tree", true),
+  EXTRACTOR_CAPTURE_TREE("extractor.capture.tree", true),
+  RETENTION_BYTES(TopicConstant.RETENTION_BYTES_KEY, false),
+  RETENTION_MS(TopicConstant.RETENTION_MS_KEY, false),
+  RETAIN_PROGRESS_AFTER_UNSUBSCRIBE(TopicConstant.RETAIN_PROGRESS_AFTER_UNSUBSCRIBE_KEY, false),
+  MODE(TopicConstant.MODE_KEY, false),
+  ORDER_MODE(TopicConstant.ORDER_MODE_KEY, false),
+  FORMAT(TopicConstant.FORMAT_KEY, false),
+  RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS(
+      TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY, false),
+  OWNER_ID(TopicConstant.OWNER_ID_KEY, false),
+  OWNER_EPOCH(TopicConstant.OWNER_EPOCH_KEY, false),
+  MAX_OWNER_EPOCH(TopicConstant.MAX_OWNER_EPOCH_KEY, false),
+  OWNER_LEASE_DURATION_MS(TopicConstant.OWNER_LEASE_DURATION_MS_KEY, false);
+
+  private static final Map<String, TopicProperty> PROPERTIES_BY_KEY;
+
+  static {
+    final Map<String, TopicProperty> properties = new HashMap<>();
+    for (final TopicProperty property : values()) {
+      properties.put(property.key, property);
+    }
+    PROPERTIES_BY_KEY = Collections.unmodifiableMap(properties);
+  }
+
+  private final String key;
+  private final boolean filteringProperty;
+
+  TopicProperty(final String key, final boolean filteringProperty) {
+    this.key = key;
+    this.filteringProperty = filteringProperty;
+  }
+
+  public String getKey() {
+    return key;
+  }
+
+  public boolean isFilteringProperty() {
+    return filteringProperty;
+  }
+
+  public static TopicProperty fromKey(final String key) {
+    return key == null ? null : PROPERTIES_BY_KEY.get(key.trim().toLowerCase(Locale.ROOT));
+  }
+
+  /** Canonicalize built-in keys while preserving extension keys and all values verbatim. */
+  public static String canonicalizeKey(final String key) {
+    final TopicProperty property = fromKey(key);
+    return property == null ? key : property.getKey();
+  }
+}
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 c09ebdf..e4b1e1c 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
@@ -241,7 +241,7 @@
     while (dataSet.hasNext()) {
       final RowRecord record = dataSet.next();
       final List<Field> fields = record.getFields();
-      if (fields.size() != 2) {
+      if (fields.size() < 2) {
         throw new SubscriptionException(
             String.format(
                 SubscriptionMessages
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 113971a..1cb614c 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
@@ -1279,7 +1279,8 @@
   }
 
   private Optional<SubscriptionMessage> pollTabletsInternal(
-      final SubscriptionPollResponse initialResponse, final PollTimer timer) {
+      final SubscriptionPollResponse initialResponse, final PollTimer timer)
+      throws SubscriptionException {
     final Map<String, List<Tablet>> tablets =
         ((TabletsPayload) initialResponse.getPayload()).getTabletsWithDBInfo();
     final SubscriptionCommitContext commitContext = initialResponse.getCommitContext();
@@ -1299,6 +1300,10 @@
           LOGGER.warn(errorMessage);
           throw new SubscriptionRuntimeNonCriticalException(errorMessage);
         }
+        if (tabletsSize == 0) {
+          ack(Collections.singletonList(new SubscriptionMessage(commitContext, tablets)));
+          return Optional.empty();
+        }
         return Optional.of(
             new SubscriptionMessage(commitContext, tablets, timeSelected, timeSelectedByTable));
       }
diff --git a/iotdb-client/subscription/src/test/java/org/apache/iotdb/rpc/subscription/config/TopicConfigTest.java b/iotdb-client/subscription/src/test/java/org/apache/iotdb/rpc/subscription/config/TopicConfigTest.java
index 0235feb..d3524b6 100644
--- a/iotdb-client/subscription/src/test/java/org/apache/iotdb/rpc/subscription/config/TopicConfigTest.java
+++ b/iotdb-client/subscription/src/test/java/org/apache/iotdb/rpc/subscription/config/TopicConfigTest.java
@@ -29,6 +29,59 @@
 public class TopicConfigTest {
 
   @Test
+  public void testFilterVersionIgnoresOwnerRetentionAndUnknownProperties() {
+    final TopicConfig original =
+        new TopicConfig(Collections.singletonMap("Tag-Filter", "region = \"north\""));
+    final Map<String, String> attributes = new HashMap<>(original.getAttribute());
+    attributes.put(TopicConstant.OWNER_ID_KEY, "owner-1");
+    attributes.put(TopicConstant.OWNER_EPOCH_KEY, "1");
+    attributes.put(TopicConstant.RETENTION_MS_KEY, "60000");
+    attributes.put(TopicConstant.RECYCLE_UNCOMMITTED_EVENT_INTERVAL_MS_KEY, "1000");
+    attributes.put("future.non-filtering-property", "value");
+    final TopicConfig updated = new TopicConfig(attributes);
+    updated.inheritFilterVersion(original);
+
+    Assert.assertEquals(original.getFilterVersion(), updated.getFilterVersion());
+  }
+
+  @Test
+  public void testFilterVersionChangesForPredicateBindingAndTreeCapture() {
+    final TopicConfig original =
+        new TopicConfig(
+            Map.of(
+                TopicConstant.TAG_FILTER_KEY, "region = \"north\"",
+                TopicConstant.DATABASE_KEY, "db",
+                TopicConstant.TABLE_KEY, "weather"));
+    for (final Map.Entry<String, String> change :
+        Map.of(
+                TopicConstant.TAG_FILTER_KEY,
+                "region = \"North\"",
+                TopicConstant.DATABASE_KEY,
+                "db2",
+                TopicConstant.TABLE_KEY,
+                "weather2",
+                "source.capture.tree",
+                "true")
+            .entrySet()) {
+      final TopicConfig updated = new TopicConfig(original);
+      updated.getAttribute().put(change.getKey(), change.getValue());
+      updated.inheritFilterVersion(original);
+      Assert.assertNotEquals(original.getFilterVersion(), updated.getFilterVersion());
+    }
+  }
+
+  @Test
+  public void testSnapshotCopyPreservesFilterVersionAndOwnsAttributes() {
+    final TopicConfig original =
+        new TopicConfig(Collections.singletonMap(TopicConstant.TAG_FILTER_KEY, "false"));
+    final TopicConfig snapshot = new TopicConfig(original);
+    Assert.assertEquals(original.getFilterVersion(), snapshot.getFilterVersion());
+
+    snapshot.getAttribute().put(TopicConstant.TAG_FILTER_KEY, "true");
+    Assert.assertEquals("false", original.getTagFilter());
+  }
+
+  @Test
   public void testModeDefaultsToInitial() {
     final TopicConfig topicConfig = new TopicConfig();
 
@@ -133,6 +186,35 @@
   }
 
   @Test
+  public void testTagFilterKeyIsCaseInsensitive() {
+    final TopicConfig topicConfig =
+        new TopicConfig(Collections.singletonMap("Tag-Filter", "region = \"north\""));
+
+    Assert.assertTrue(topicConfig.hasTagFilter());
+    Assert.assertEquals("region = \"north\"", topicConfig.getTagFilter());
+    Assert.assertEquals(
+        "region = \"north\"",
+        topicConfig.getAttributesWithSourceTagFilter().get(TopicConstant.TAG_FILTER_KEY));
+  }
+
+  @Test
+  public void testTagFilterDefaultsToTrivialWhenAbsent() {
+    final TopicConfig topicConfig = new TopicConfig(new HashMap<>());
+
+    Assert.assertFalse(topicConfig.hasTagFilter());
+    Assert.assertTrue(topicConfig.isTagFilterTrivial());
+    Assert.assertEquals(TopicConstant.TAG_FILTER_DEFAULT_VALUE, topicConfig.getTagFilter());
+  }
+
+  @Test
+  public void testTagFilterTrivialWithMixedCaseKeyAndValue() {
+    final Map<String, String> attributes = new HashMap<>();
+    attributes.put("TAG-FILTER", " TRUE ");
+
+    Assert.assertTrue(new TopicConfig(attributes).isTagFilterTrivial());
+  }
+
+  @Test
   public void testRetainProgressAfterUnsubscribeDefaultsToFalse() {
     Assert.assertFalse(new TopicConfig().isProgressRetainedAfterUnsubscribe());
   }
diff --git a/iotdb-client/subscription/src/test/java/org/apache/iotdb/session/subscription/consumer/base/SubscriptionConsumerMultiProviderPollTest.java b/iotdb-client/subscription/src/test/java/org/apache/iotdb/session/subscription/consumer/base/SubscriptionConsumerMultiProviderPollTest.java
index 34ef7a8..5faeff9 100644
--- a/iotdb-client/subscription/src/test/java/org/apache/iotdb/session/subscription/consumer/base/SubscriptionConsumerMultiProviderPollTest.java
+++ b/iotdb-client/subscription/src/test/java/org/apache/iotdb/session/subscription/consumer/base/SubscriptionConsumerMultiProviderPollTest.java
@@ -39,6 +39,7 @@
 import org.junit.Assert;
 import org.junit.Test;
 
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -93,14 +94,41 @@
     }
   }
 
+  @Test
+  public void testFullyFilteredTabletBatchIsAcknowledgedAndPollReturnsNoMessages()
+      throws SubscriptionException {
+    final TestPullConsumer consumer = new TestPullConsumer(1, 0, true);
+    try {
+      consumer.open();
+      consumer.subscribeTopic();
+
+      final List<SubscriptionMessage> messages = consumer.pollForTest(1_000L);
+
+      Assert.assertTrue(messages.isEmpty());
+      Assert.assertEquals(1, consumer.getAcknowledgedCommitContexts().size());
+      Assert.assertEquals(
+          new SubscriptionCommitContext(1, 0, TOPIC, CONSUMER_GROUP_ID, 0L),
+          consumer.getAcknowledgedCommitContexts().get(0));
+    } finally {
+      consumer.close();
+    }
+  }
+
   private static class TestPullConsumer extends AbstractSubscriptionPullConsumer {
 
     private final Map<Integer, Integer> pollCounts = new HashMap<>();
+    private final List<SubscriptionCommitContext> acknowledgedCommitContexts = new ArrayList<>();
     private final int dataProviderId;
     private final int emptyPollsBeforeData;
+    private final boolean emptyTabletBatch;
     private int backoffCount;
 
     private TestPullConsumer(final int dataProviderId, final int emptyPollsBeforeData) {
+      this(dataProviderId, emptyPollsBeforeData, false);
+    }
+
+    private TestPullConsumer(
+        final int dataProviderId, final int emptyPollsBeforeData, final boolean emptyTabletBatch) {
       super(
           new AbstractSubscriptionPullConsumerBuilder()
               .host(HOST)
@@ -112,6 +140,7 @@
               .autoCommit(false));
       this.dataProviderId = dataProviderId;
       this.emptyPollsBeforeData = emptyPollsBeforeData;
+      this.emptyTabletBatch = emptyTabletBatch;
     }
 
     @Override
@@ -141,7 +170,8 @@
           connectionTimeoutInMs,
           pollCounts,
           dataProviderId,
-          emptyPollsBeforeData);
+          emptyPollsBeforeData,
+          emptyTabletBatch);
     }
 
     private void subscribeTopic() {
@@ -161,6 +191,17 @@
       return backoffCount;
     }
 
+    private List<SubscriptionCommitContext> getAcknowledgedCommitContexts() {
+      return acknowledgedCommitContexts;
+    }
+
+    @Override
+    protected void ack(final Iterable<SubscriptionMessage> messages) throws SubscriptionException {
+      for (final SubscriptionMessage message : messages) {
+        acknowledgedCommitContexts.add(message.getCommitContext());
+      }
+    }
+
     @Override
     void sleepAfterEmptyPollRound() {
       backoffCount++;
@@ -173,6 +214,7 @@
     private final Map<Integer, Integer> pollCounts;
     private final int dataProviderId;
     private final int emptyPollsBeforeData;
+    private final boolean emptyTabletBatch;
 
     private TestSubscriptionProvider(
         final TEndPoint endPoint,
@@ -188,7 +230,8 @@
         final int connectionTimeoutInMs,
         final Map<Integer, Integer> pollCounts,
         final int dataProviderId,
-        final int emptyPollsBeforeData) {
+        final int emptyPollsBeforeData,
+        final boolean emptyTabletBatch) {
       super(
           endPoint,
           username,
@@ -205,6 +248,7 @@
       this.pollCounts = pollCounts;
       this.dataProviderId = dataProviderId;
       this.emptyPollsBeforeData = emptyPollsBeforeData;
+      this.emptyTabletBatch = emptyTabletBatch;
     }
 
     @Override
@@ -265,6 +309,18 @@
       }
       final SubscriptionCommitContext commitContext =
           new SubscriptionCommitContext(dataNodeId, 0, TOPIC, CONSUMER_GROUP_ID, 0L);
+      if (emptyTabletBatch) {
+        final int pollCount = pollCounts.get(dataNodeId);
+        if (pollCount != emptyPollsBeforeData + 1) {
+          return Collections.emptyList();
+        }
+        return new ArrayList<>(
+            Collections.singletonList(
+                new SubscriptionPollResponse(
+                    SubscriptionPollResponseType.TABLETS.getType(),
+                    new TabletsPayload(Collections.emptyMap(), 1),
+                    commitContext)));
+      }
       final List<IMeasurementSchema> schemas =
           Collections.singletonList(new MeasurementSchema("s1", TSDataType.INT64));
       final Tablet tablet = new Tablet("root.sg.d1", schemas, 1);
@@ -277,5 +333,19 @@
               new TabletsPayload(Collections.singletonList(tablet), -1),
               commitContext));
     }
+
+    @Override
+    List<SubscriptionPollResponse> pollTablets(
+        final SubscriptionCommitContext commitContext, final int offset, final long timeoutMs)
+        throws SubscriptionException {
+      if (emptyTabletBatch && dataNodeId == dataProviderId && offset == 1) {
+        return Collections.singletonList(
+            new SubscriptionPollResponse(
+                SubscriptionPollResponseType.TABLETS.getType(),
+                new TabletsPayload(Collections.emptyMap(), 0),
+                commitContext));
+      }
+      return Collections.emptyList();
+    }
   }
 }
diff --git a/iotdb-core/confignode/src/main/i18n/en/org/apache/iotdb/confignode/i18n/ConfigNodeMessages.java b/iotdb-core/confignode/src/main/i18n/en/org/apache/iotdb/confignode/i18n/ConfigNodeMessages.java
index 4aea615..cfde56c 100644
--- a/iotdb-core/confignode/src/main/i18n/en/org/apache/iotdb/confignode/i18n/ConfigNodeMessages.java
+++ b/iotdb-core/confignode/src/main/i18n/en/org/apache/iotdb/confignode/i18n/ConfigNodeMessages.java
@@ -719,6 +719,15 @@
       EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_ARG_AND_ARG_ARE_ONLY_SUPPORTED_FOR_INCREMENTAL_TOPICS_D86CEA8E =
           "Failed to create or alter topic, %s and %s are only supported for incremental topics";
   public static final String
+      EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_DUPLICATE_ARG_ATTRIBUTES_ARE_NOT_ALLOWED_27315578 =
+          "Failed to create or alter topic, duplicate %s attributes are not allowed";
+  public static final String
+      EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_ARG_IS_ONLY_SUPPORTED_FOR_TABLE_TOPICS_A5126607 =
+          "Failed to create or alter topic, %s is only supported for table topics";
+  public static final String
+      EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_ARG_SHOULD_NOT_BE_EMPTY_767B1148 =
+          "Failed to create or alter topic, %s should not be empty";
+  public static final String
       EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_ATTRIBUTES_ARG_ARE_ONLY_SUPPORTED_FOR_INCREMENTAL_TOPICS_55CB0BF2 =
           "Failed to create or alter topic, attributes %s are only supported for incremental topics";
   public static final String
diff --git a/iotdb-core/confignode/src/main/i18n/zh/org/apache/iotdb/confignode/i18n/ConfigNodeMessages.java b/iotdb-core/confignode/src/main/i18n/zh/org/apache/iotdb/confignode/i18n/ConfigNodeMessages.java
index 35436ee..ddf8be6 100644
--- a/iotdb-core/confignode/src/main/i18n/zh/org/apache/iotdb/confignode/i18n/ConfigNodeMessages.java
+++ b/iotdb-core/confignode/src/main/i18n/zh/org/apache/iotdb/confignode/i18n/ConfigNodeMessages.java
@@ -761,6 +761,15 @@
       EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_ARG_AND_ARG_ARE_ONLY_SUPPORTED_FOR_INCREMENTAL_TOPICS_D86CEA8E =
           "创建或修改 topic 失败,%s 和 %s 仅支持 incremental 模式的 topic";
   public static final String
+      EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_DUPLICATE_ARG_ATTRIBUTES_ARE_NOT_ALLOWED_27315578 =
+          "创建或修改 Topic 失败,不允许重复的 %s 属性";
+  public static final String
+      EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_ARG_IS_ONLY_SUPPORTED_FOR_TABLE_TOPICS_A5126607 =
+          "创建或修改 Topic 失败,%s 仅支持表模型 Topic";
+  public static final String
+      EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_ARG_SHOULD_NOT_BE_EMPTY_767B1148 =
+          "创建或修改 Topic 失败,%s 不应为空";
+  public static final String
       EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_ATTRIBUTES_ARG_ARE_ONLY_SUPPORTED_FOR_INCREMENTAL_TOPICS_55CB0BF2 =
           "创建或修改 topic 失败,属性 %s 仅支持 incremental 模式的 topic";
   public static final String
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 c0b6d10..79a735d 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
@@ -54,6 +54,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.db.subscription.tagfilter.TagFilterParser;
 import org.apache.iotdb.mpp.rpc.thrift.TTopicOwnerLeaseEntry;
 import org.apache.iotdb.pipe.api.exception.PipeException;
 import org.apache.iotdb.rpc.TSStatusCode;
@@ -92,6 +93,7 @@
 public class SubscriptionInfo implements SnapshotProcessor {
 
   private static final Logger LOGGER = LoggerFactory.getLogger(SubscriptionInfo.class);
+  private static final TagFilterParser TAG_FILTER_PARSER = new TagFilterParser();
 
   private static final ConfigNodeConfig CONF = ConfigNodeDescriptor.getInstance().getConf();
 
@@ -106,6 +108,7 @@
           TopicConstant.DATABASE_KEY,
           TopicConstant.TABLE_KEY,
           TopicConstant.COLUMN_FILTER_KEY,
+          TopicConstant.TAG_FILTER_KEY,
           TopicConstant.RETENTION_BYTES_KEY,
           TopicConstant.RETENTION_MS_KEY,
           TopicConstant.RETAIN_PROGRESS_AFTER_UNSUBSCRIBE_KEY,
@@ -129,6 +132,7 @@
           TopicConstant.DATABASE_KEY,
           TopicConstant.TABLE_KEY,
           TopicConstant.COLUMN_FILTER_KEY,
+          TopicConstant.TAG_FILTER_KEY,
           TopicConstant.RETENTION_BYTES_KEY,
           TopicConstant.RETENTION_MS_KEY,
           TopicConstant.RETAIN_PROGRESS_AFTER_UNSUBSCRIBE_KEY,
@@ -329,6 +333,11 @@
     }
   }
 
+  public void validateUpdatedTopicAttributes(final Map<String, String> updatedAttributes)
+      throws SubscriptionException {
+    validateDuplicateTopicAttributes(new TopicConfig(safeTopicAttributes(updatedAttributes)));
+  }
+
   private void checkBeforeAlteringTopicInternal(TopicMeta topicMeta) throws SubscriptionException {
     validateTopicConfig(topicMeta.getConfig());
 
@@ -400,6 +409,7 @@
     }
 
     validateColumnFilter(topicConfig);
+    validateTagFilter(topicConfig);
     validateIncrementalTopicRetentionConfig(topicConfig);
     validateRecycleUncommittedEventInterval(topicConfig);
 
@@ -553,7 +563,8 @@
       if (!seenKeys.add(normalizedKey)) {
         final String exceptionMessage =
             String.format(
-                "Failed to create or alter topic, duplicate %s attributes are not allowed",
+                ConfigNodeMessages
+                    .EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_DUPLICATE_ARG_ATTRIBUTES_ARE_NOT_ALLOWED_27315578,
                 normalizedKey);
         LOGGER.warn(exceptionMessage);
         throw new SubscriptionException(exceptionMessage);
@@ -562,24 +573,50 @@
   }
 
   private void validateColumnFilter(final TopicConfig topicConfig) throws SubscriptionException {
-    if (!topicConfig.hasColumnFilter()) {
+    validateTableOnlyFilter(
+        topicConfig,
+        TopicConstant.COLUMN_FILTER_KEY,
+        topicConfig.hasColumnFilter(),
+        topicConfig.getColumnFilter());
+  }
+
+  private void validateTagFilter(final TopicConfig topicConfig) throws SubscriptionException {
+    validateTableOnlyFilter(
+        topicConfig,
+        TopicConstant.TAG_FILTER_KEY,
+        topicConfig.hasTagFilter(),
+        topicConfig.getTagFilter());
+    if (topicConfig.hasTagFilter()) {
+      TAG_FILTER_PARSER.parseAndValidate(topicConfig.getTagFilter());
+    }
+  }
+
+  private void validateTableOnlyFilter(
+      final TopicConfig topicConfig,
+      final String filterKey,
+      final boolean hasFilter,
+      final String filter)
+      throws SubscriptionException {
+    if (!hasFilter) {
       return;
     }
 
     if (!topicConfig.isTableTopic()) {
       final String exceptionMessage =
           String.format(
-              "Failed to create or alter topic, %s is only supported for table topics",
-              TopicConstant.COLUMN_FILTER_KEY);
+              ConfigNodeMessages
+                  .EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_ARG_IS_ONLY_SUPPORTED_FOR_TABLE_TOPICS_A5126607,
+              filterKey);
       LOGGER.warn(exceptionMessage);
       throw new SubscriptionException(exceptionMessage);
     }
 
-    if (topicConfig.getColumnFilter().trim().isEmpty()) {
+    if (filter.trim().isEmpty()) {
       final String exceptionMessage =
           String.format(
-              "Failed to create or alter topic, %s should not be empty",
-              TopicConstant.COLUMN_FILTER_KEY);
+              ConfigNodeMessages
+                  .EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_ARG_SHOULD_NOT_BE_EMPTY_767B1148,
+              filterKey);
       LOGGER.warn(exceptionMessage);
       throw new SubscriptionException(exceptionMessage);
     }
diff --git a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/procedure/impl/subscription/topic/AlterTopicProcedure.java b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/procedure/impl/subscription/topic/AlterTopicProcedure.java
index 1f6cc40..fb7ff2f 100644
--- a/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/procedure/impl/subscription/topic/AlterTopicProcedure.java
+++ b/iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/procedure/impl/subscription/topic/AlterTopicProcedure.java
@@ -130,6 +130,7 @@
             .deepCopyTopicMeta(
                 updatedTopicMeta.getTopicName(), updatedTopicMeta.visibleUnderTableModel());
     if (Objects.nonNull(updatedTopicAttributes) && Objects.nonNull(existedTopicMeta)) {
+      subscriptionInfo.get().validateUpdatedTopicAttributes(updatedTopicAttributes);
       updatedTopicMeta = existedTopicMeta.deepCopyWithUpdatedAttributes(updatedTopicAttributes);
     }
 
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 f9e7e7a..fd97beb 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
@@ -87,6 +87,75 @@
   }
 
   @Test
+  public void testValidateTagFilterOnCreate() throws Exception {
+    final SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
+    final Map<String, String> attributes = newIncrementalTableTopicAttributes();
+    attributes.put(TopicConstant.TAG_FILTER_KEY, "region IN (\"north\", \"south\")");
+
+    Assert.assertTrue(
+        subscriptionInfo.validateBeforeCreatingTopic(
+            new TCreateTopicReq("table_topic").setTopicAttributes(attributes)));
+  }
+
+  @Test
+  public void testRejectInvalidTagFilterFromSessionApiOnCreate() {
+    final SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
+    final Map<String, String> attributes = newIncrementalTableTopicAttributes();
+    attributes.put(TopicConstant.TAG_FILTER_KEY, "region = north");
+
+    assertCreateRejected(subscriptionInfo, attributes, "Invalid tag-filter");
+  }
+
+  @Test
+  public void testRejectInvalidTagFilterFromSessionApiOnAlter() throws Exception {
+    final SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
+    final Map<String, String> originalAttributes = newIncrementalTableTopicAttributes();
+    originalAttributes.put(TopicConstant.TAG_FILTER_KEY, "region = \"north\"");
+    subscriptionInfo.createTopic(
+        new CreateTopicPlan(new TopicMeta("table_topic", 1L, originalAttributes)));
+
+    final Map<String, String> updatedAttributes = newIncrementalTableTopicAttributes();
+    updatedAttributes.put(TopicConstant.TAG_FILTER_KEY, "region IN ()");
+    try {
+      subscriptionInfo.validateBeforeAlteringTopic(
+          new TopicMeta("table_topic", 2L, updatedAttributes));
+      Assert.fail("Expected invalid tag-filter alteration to fail");
+    } catch (final SubscriptionException e) {
+      Assert.assertTrue(e.getMessage().contains("Invalid tag-filter"));
+    }
+  }
+
+  @Test
+  public void testRejectTagFilterOnTreeTopic() {
+    final SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
+    final Map<String, String> attributes = new HashMap<>();
+    attributes.put(TopicConstant.TAG_FILTER_KEY, "region = \"north\"");
+
+    assertCreateRejected(subscriptionInfo, attributes, "only supported for table topics");
+  }
+
+  @Test
+  public void testTagFilterKeyIsCaseInsensitiveOnCreate() throws Exception {
+    final SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
+    final Map<String, String> attributes = newInitialTableTopicAttributes();
+    attributes.put("Tag-Filter", "region = \"north\"");
+
+    Assert.assertTrue(
+        subscriptionInfo.validateBeforeCreatingTopic(
+            new TCreateTopicReq("table_topic").setTopicAttributes(attributes)));
+  }
+
+  @Test
+  public void testRejectDuplicateTagFilterKeys() {
+    final SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
+    final Map<String, String> attributes = newInitialTableTopicAttributes();
+    attributes.put(TopicConstant.TAG_FILTER_KEY, "region = \"north\"");
+    attributes.put("Tag-Filter", "region = \"south\"");
+
+    assertCreateRejected(subscriptionInfo, attributes, "duplicate tag-filter");
+  }
+
+  @Test
   public void testRejectTopicThatOnlySelectsAuditDatabase() {
     final SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
 
@@ -251,6 +320,15 @@
   }
 
   @Test
+  public void testRejectEmptyTagFilter() {
+    final SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
+    final Map<String, String> attributes = newIncrementalTableTopicAttributes();
+    attributes.put(TopicConstant.TAG_FILTER_KEY, " ");
+
+    assertCreateRejected(subscriptionInfo, attributes, "tag-filter should not be empty");
+  }
+
+  @Test
   public void testAcceptAlteringColumnFilter() throws Exception {
     final SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
     final Map<String, String> originalAttributes = newIncrementalTableTopicAttributes();
diff --git a/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/procedure/impl/subscription/topic/AlterTopicProcedureTest.java b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/procedure/impl/subscription/topic/AlterTopicProcedureTest.java
index 3f553cb..56f16b6 100644
--- a/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/procedure/impl/subscription/topic/AlterTopicProcedureTest.java
+++ b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/procedure/impl/subscription/topic/AlterTopicProcedureTest.java
@@ -25,6 +25,8 @@
 import org.apache.iotdb.confignode.persistence.subscription.SubscriptionInfo;
 import org.apache.iotdb.confignode.procedure.store.ProcedureFactory;
 import org.apache.iotdb.rpc.TSStatusCode;
+import org.apache.iotdb.rpc.subscription.config.TopicConstant;
+import org.apache.iotdb.rpc.subscription.exception.SubscriptionException;
 
 import org.apache.tsfile.utils.PublicBAOS;
 import org.junit.Test;
@@ -36,6 +38,8 @@
 import java.util.concurrent.atomic.AtomicReference;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 public class AlterTopicProcedureTest {
 
@@ -105,4 +109,34 @@
     assertEquals("processor1", procedure.getUpdatedTopicMeta().getConfig().getString("processor"));
     assertEquals("source1", procedure.getUpdatedTopicMeta().getConfig().getString("source"));
   }
+
+  @Test
+  public void testRejectCaseInsensitiveDuplicateAttributesBeforeMerge() throws Exception {
+    final String topicName = "test_table_topic";
+    final SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
+    final Map<String, String> initialAttributes = new HashMap<>();
+    initialAttributes.put("__system.sql-dialect", "table");
+    assertEquals(
+        TSStatusCode.SUCCESS_STATUS.getStatusCode(),
+        subscriptionInfo
+            .createTopic(new CreateTopicPlan(new TopicMeta(topicName, 1, initialAttributes)))
+            .getCode());
+
+    final Map<String, String> requestAttributes = new HashMap<>();
+    requestAttributes.put(TopicConstant.TAG_FILTER_KEY, "region = \"north\"");
+    requestAttributes.put("TAG-FILTER", "region = \"south\"");
+    final AlterTopicProcedure procedure =
+        new AlterTopicProcedure(
+            subscriptionInfo.deepCopyTopicMetaWithUpdatedAttributes(
+                topicName, requestAttributes, true),
+            requestAttributes,
+            new AtomicReference<>(subscriptionInfo));
+
+    try {
+      procedure.executeFromValidate(null);
+      fail("Expected duplicate tag-filter attributes to be rejected");
+    } catch (final SubscriptionException e) {
+      assertTrue(e.getMessage(), e.getMessage().contains("duplicate tag-filter"));
+    }
+  }
 }
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 43cd511..b4271e3 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
@@ -123,6 +123,84 @@
   public static final String COLUMN_FILTER_LIKE_PATTERN = "LIKE pattern";
   public static final String COLUMN_FILTER_LIKE_ESCAPE = "LIKE escape";
   public static final String COLUMN_FILTER_REGEXP_PATTERN = "REGEXP pattern";
+  public static final String EXCEPTION_INVALID_TAG_FILTER_ARG_E4B1C1C6 =
+      "Invalid tag-filter: %s";
+  public static final String EXCEPTION_TAG_FILTER_SHOULD_NOT_BE_EMPTY_507CA5B0 =
+      "tag-filter should not be empty";
+  public static final String
+      EXCEPTION_ONLY_AND_COMPARISONS_ARE_SUPPORTED_IN_TAG_FILTER_19946958 =
+          "only =, !=, and <> comparisons are supported in tag-filter";
+  public static final String EXCEPTION_LEFT_OPERAND_MUST_BE_A_TAG_COLUMN_F9D4548B =
+      "left operand must be a TAG column";
+  public static final String EXCEPTION_TAG_FILTER_EVALUATION_FAILED_ARG_1B239B2F =
+      "Tag-filter evaluation failed: %s";
+  public static final String EXCEPTION_TAG_FILTER_SCHEMA_BINDING_FAILED_ARG_7A5D2D47 =
+      "Tag-filter schema binding failed: %s";
+  public static final String EXCEPTION_TAG_FILTER_EXCEEDS_MAXIMUM_UTF_8_LENGTH_OF_ARG_BYTES_9C8400F2 =
+      "tag-filter exceeds maximum UTF-8 length of %s bytes";
+  public static final String EXCEPTION_TAG_FILTER_AST_DEPTH_EXCEEDS_MAXIMUM_OF_ARG_64C92731 =
+      "tag-filter AST depth exceeds maximum of %s";
+  public static final String EXCEPTION_TAG_FILTER_AST_NODE_COUNT_EXCEEDS_MAXIMUM_OF_ARG_40CCE694 =
+      "tag-filter AST node count exceeds maximum of %s";
+  public static final String EXCEPTION_TAG_FILTER_IN_LIST_EXCEEDS_MAXIMUM_OF_ARG_VALUES_07E7FC6C =
+      "tag-filter IN list exceeds maximum of %s values";
+  public static final String EXCEPTION_TAG_FILTER_REGEXP_PATTERN_EXCEEDS_MAXIMUM_LENGTH_OF_ARG_CHARACTERS_67590971 =
+      "tag-filter REGEXP pattern exceeds maximum length of %s characters";
+  public static final String EXCEPTION_TABLE_SCHEMA_IS_NOT_AVAILABLE_FOR_TAG_FILTER_993AB728 =
+      "table schema is not available for tag-filter";
+  public static final String
+      EXCEPTION_TABLE_SCHEMA_ARG_ARG_IS_ABSENT_FROM_THIS_DATANODE_S_TABLE_CACHE_THE_TABLE_MAY_NOT_EXIST_OR_ITS_SCHEMA_HAS_NOT_BEEN_SYNCHRONIZED_YET_23975BDF =
+          "Table schema %s.%s is absent from this DataNode's table cache; the table may not exist or its schema has not been synchronized yet";
+  public static final String
+      EXCEPTION_TABLE_SCHEMA_KEPT_CHANGING_WHILE_BINDING_TAG_FILTER_FOR_DATABASE_PATTERN_ARG_AND_TABLE_PATTERN_ARG_RETRY_AFTER_SCHEMA_SYNCHRONIZATION_77A005E9 =
+          "Table schema kept changing while binding tag-filter for database pattern %s and table pattern %s; retry after schema synchronization";
+  public static final String EXCEPTION_REFERENCED_TAG_COLUMN_IS_MISSING_ARG_F5300BEA =
+      "referenced TAG column is missing: %s";
+  public static final String EXCEPTION_REFERENCED_COLUMN_IS_NOT_A_TAG_COLUMN_ARG_34D60881 =
+      "referenced column is not a TAG column: %s";
+  public static final String EXCEPTION_UNCOMPILED_LIKE_PREDICATE_01AEE439 =
+      "uncompiled LIKE predicate";
+  public static final String EXCEPTION_UNCOMPILED_REGEXP_PREDICATE_2B0DD646 =
+      "uncompiled REGEXP predicate";
+  public static final String EXCEPTION_MATCHER_IS_UNAVAILABLE_1A659D47 = "matcher is unavailable";
+  public static final String EXCEPTION_TABLET_SCHEMA_IS_MISSING_164075B0 = "tablet schema is missing";
+  public static final String EXCEPTION_TABLET_VALUE_COLUMNS_ARE_INCOMPLETE_DCD09F3C =
+      "tablet value columns are incomplete";
+  public static final String EXCEPTION_FAILED_TO_EVALUATE_A_TABLET_ROW_7E4E94CE =
+      "failed to evaluate a tablet row";
+  public static final String EXCEPTION_TABLE_BINDING_IS_NOT_AVAILABLE_882C5F2F =
+      "table binding is not available";
+  public static final String EXCEPTION_REFERENCED_COLUMN_IS_NOT_A_TAG_COLUMN_33FA34BC =
+      "referenced column is not a TAG column";
+  public static final String EXCEPTION_REFERENCED_COLUMN_CATEGORY_CHANGED_564594D8 =
+      "referenced column category changed";
+  public static final String EXCEPTION_REFERENCED_TAG_COLUMN_IS_MISSING_55E07377 =
+      "referenced TAG column is missing";
+  public static final String EXCEPTION_TABLET_MEASUREMENT_SCHEMA_IS_INCOMPLETE_6A813472 =
+      "tablet measurement schema is incomplete";
+  public static final String EXCEPTION_FAILED_TO_COMPACT_FILTERED_TABLET_5D4AD8AA =
+      "failed to compact filtered tablet";
+  public static final String EXCEPTION_TABLET_COLUMN_CATEGORIES_ARE_MISSING_2C660532 =
+      "tablet column categories are missing";
+  public static final String EXCEPTION_TABLET_COLUMN_CATEGORY_IS_MISSING_A812B500 =
+      "tablet column category is missing";
+  public static final String EXCEPTION_TABLET_TIMESTAMPS_ARE_INCOMPLETE_24F8CE6F =
+      "tablet timestamps are incomplete";
+  public static final String EXCEPTION_TABLET_BITMAPS_ARE_INCOMPLETE_7BBC8035 =
+      "tablet bitmaps are incomplete";
+  public static final String EXCEPTION_TABLET_VALUE_COLUMN_IS_INCOMPLETE_845721FE =
+      "tablet value column is incomplete";
+  public static final String EXCEPTION_TREE_VIEW_PROJECTOR_IS_UNAVAILABLE_FOR_FILTERED_SUBSCRIPTION_DATA_B5F396A5 =
+      "Tree View projector is unavailable for filtered subscription data";
+  public static final String
+      EXCEPTION_TOPIC_CONFIGURATION_IS_NOT_AVAILABLE_FOR_TAG_FILTER_1E023E3A =
+          "topic configuration is not available for tag-filter";
+  public static final String
+      EXCEPTION_TOPIC_CONFIGURATION_CHANGED_WHILE_CAPTURING_TAG_FILTER_SNAPSHOT_984CEB1B =
+          "topic configuration changed while capturing tag-filter snapshot";
+  public static final String
+      LOG_SUBSCRIPTION_TABLE_SCHEMA_IS_NOT_AVAILABLE_FOR_TAG_FILTER_TOPIC_ARG_E864C98F =
+      "Subscription: table schema is not available for tag-filter topic [{}]";
 
   public static final String CREATE_NEW_REGION_ERROR_FMT = "create new region %s error,  exception:%s";
   public static final String CREATE_NEW_REGION_SUCCEED_FMT = "create new region %s succeed";
@@ -793,6 +871,14 @@
       "Subscription: failed to lazily refresh column-filter matcher for topic [{}]";
   public static final String SUBSCRIPTION_DROP_COLUMN_FILTER =
       "Subscription: dropped column-filter matcher for topic [{}]";
+  public static final String
+      LOG_SUBSCRIPTION_FAILED_TO_REFRESH_TAG_FILTER_MATCHER_FOR_TOPIC_ARG_USE_EMPTY_MATCHER_TO_FAIL_CLOSED_3CB76D70 =
+          "Subscription: failed to refresh tag-filter matcher for topic [{}], use empty matcher to fail closed";
+  public static final String
+      LOG_SUBSCRIPTION_REFRESHED_TAG_FILTER_MATCHER_FOR_TOPIC_ARG_71598849 =
+          "Subscription: refreshed tag-filter matcher for topic [{}]";
+  public static final String LOG_SUBSCRIPTION_DROPPED_TAG_FILTER_MATCHER_FOR_TOPIC_ARG_9EA52458 =
+      "Subscription: dropped tag-filter matcher for topic [{}]";
   public static final String SUBSCRIPTION_UNSUPPORTED_CONSENSUS_PROGRESS_FILE_VERSION_FMT =
       "Unsupported consensus subscription progress file version %s";
 
@@ -833,6 +919,8 @@
       "Exception occurred when sealing events from batch {}";
   public static final String EXCEPTION_CONSTRUCT_NEW_BATCH =
       "Exception occurred when construct new batch";
+  public static final String EXCEPTION_FAILED_TO_SEAL_SUBSCRIPTION_EVENT_BATCH_1FB7E92C =
+      "Failed to seal subscription event batch";
 
   // ---------------------------------------------------------------------------
   // subscription – SubscriptionPrefetchingQueue
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 7dd677c..d3282b7 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
@@ -4054,6 +4054,12 @@
           "No materializer is available to enforce the DeviceEntry memory limit";
   public static final String EXCEPTION_NO_MORE_DEVICEENTRY_RECORDS_ARE_AVAILABLE_8D51C199 =
       "No more DeviceEntry records are available";
+  public static final String
+      EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_DUPLICATE_ARG_ATTRIBUTES_ARE_NOT_ALLOWED_27315578 =
+          "Failed to create or alter topic, duplicate %s attributes are not allowed";
+  public static final String
+      EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_ARG_IS_ONLY_SUPPORTED_FOR_TABLE_TOPICS_A5126607 =
+          "Failed to create or alter topic, %s is only supported for table topics";
   public static final String EXCEPTION_ONLY_INMEMORYDEVICEENTRYDATASET_SUPPORTS_GET_INLINE_DEVICE_ENTRIES_07A52CAB =
       "Only InMemoryDeviceEntryDataSet supports get inline device entries";
   public static final String EXCEPTION_LOAD_CONSENSUS_INVALID_PIECE_REF_F3498507 =
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 743d1aa..0139e97 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
@@ -123,6 +123,86 @@
   public static final String COLUMN_FILTER_LIKE_PATTERN = "LIKE 模式";
   public static final String COLUMN_FILTER_LIKE_ESCAPE = "LIKE 转义符";
   public static final String COLUMN_FILTER_REGEXP_PATTERN = "REGEXP 模式";
+  public static final String EXCEPTION_INVALID_TAG_FILTER_ARG_E4B1C1C6 =
+      "\u65e0\u6548\u7684 tag-filter\uff1a%s";
+  public static final String EXCEPTION_TAG_FILTER_SHOULD_NOT_BE_EMPTY_507CA5B0 =
+      "tag-filter \u4e0d\u5e94\u4e3a\u7a7a";
+  public static final String
+      EXCEPTION_ONLY_AND_COMPARISONS_ARE_SUPPORTED_IN_TAG_FILTER_19946958 =
+          "tag-filter \u4ec5\u652f\u6301 =\u3001!= \u548c <> \u6bd4\u8f83";
+  public static final String EXCEPTION_LEFT_OPERAND_MUST_BE_A_TAG_COLUMN_F9D4548B =
+      "\u5de6\u64cd\u4f5c\u6570\u5fc5\u987b\u662f TAG \u5217";
+  public static final String EXCEPTION_TAG_FILTER_EVALUATION_FAILED_ARG_1B239B2F =
+      "tag-filter \u6c42\u503c\u5931\u8d25\uff1a%s";
+  public static final String EXCEPTION_TAG_FILTER_SCHEMA_BINDING_FAILED_ARG_7A5D2D47 =
+      "tag-filter Schema \u7ed1\u5b9a\u5931\u8d25\uff1a%s";
+  public static final String EXCEPTION_TAG_FILTER_EXCEEDS_MAXIMUM_UTF_8_LENGTH_OF_ARG_BYTES_9C8400F2 =
+      "tag-filter \u8d85\u8fc7 UTF-8 \u6700\u5927\u957f\u5ea6 %s \u5b57\u8282";
+  public static final String EXCEPTION_TAG_FILTER_AST_DEPTH_EXCEEDS_MAXIMUM_OF_ARG_64C92731 =
+      "tag-filter AST \u6df1\u5ea6\u8d85\u8fc7\u6700\u5927\u503c %s";
+  public static final String EXCEPTION_TAG_FILTER_AST_NODE_COUNT_EXCEEDS_MAXIMUM_OF_ARG_40CCE694 =
+      "tag-filter AST \u8282\u70b9\u6570\u8d85\u8fc7\u6700\u5927\u503c %s";
+  public static final String EXCEPTION_TAG_FILTER_IN_LIST_EXCEEDS_MAXIMUM_OF_ARG_VALUES_07E7FC6C =
+      "tag-filter IN \u5217\u8868\u8d85\u8fc7\u6700\u5927\u503c %s";
+  public static final String EXCEPTION_TAG_FILTER_REGEXP_PATTERN_EXCEEDS_MAXIMUM_LENGTH_OF_ARG_CHARACTERS_67590971 =
+      "tag-filter REGEXP \u6a21\u5f0f\u8d85\u8fc7\u6700\u5927\u957f\u5ea6 %s \u5b57\u7b26";
+  public static final String
+      EXCEPTION_TABLE_SCHEMA_ARG_ARG_IS_ABSENT_FROM_THIS_DATANODE_S_TABLE_CACHE_THE_TABLE_MAY_NOT_EXIST_OR_ITS_SCHEMA_HAS_NOT_BEEN_SYNCHRONIZED_YET_23975BDF =
+          "此 DataNode 的表缓存中没有表结构 %s.%s;该表可能不存在,或其结构尚未同步";
+  public static final String
+      EXCEPTION_TABLE_SCHEMA_KEPT_CHANGING_WHILE_BINDING_TAG_FILTER_FOR_DATABASE_PATTERN_ARG_AND_TABLE_PATTERN_ARG_RETRY_AFTER_SCHEMA_SYNCHRONIZATION_77A005E9 =
+          "为数据库模式 %s 和表模式 %s 绑定 tag-filter 时表结构持续变化;请在结构同步后重试";
+  public static final String EXCEPTION_TABLE_SCHEMA_IS_NOT_AVAILABLE_FOR_TAG_FILTER_993AB728 =
+      "tag-filter \u6240\u9700\u8868\u7ed3\u6784\u4e0d\u53ef\u7528";
+  public static final String EXCEPTION_REFERENCED_TAG_COLUMN_IS_MISSING_ARG_F5300BEA =
+      "\u5f15\u7528\u7684 TAG \u5217\u4e0d\u5b58\u5728\uff1a%s";
+  public static final String EXCEPTION_REFERENCED_COLUMN_IS_NOT_A_TAG_COLUMN_ARG_34D60881 =
+      "\u5f15\u7528\u7684\u5217\u4e0d\u662f TAG \u5217\uff1a%s";
+  public static final String EXCEPTION_UNCOMPILED_LIKE_PREDICATE_01AEE439 =
+      "LIKE \u8c13\u8bcd\u672a\u7f16\u8bd1";
+  public static final String EXCEPTION_UNCOMPILED_REGEXP_PREDICATE_2B0DD646 =
+      "REGEXP \u8c13\u8bcd\u672a\u7f16\u8bd1";
+  public static final String EXCEPTION_MATCHER_IS_UNAVAILABLE_1A659D47 =
+      "tag-filter \u5339\u914d\u5668\u4e0d\u53ef\u7528";
+  public static final String EXCEPTION_TABLET_SCHEMA_IS_MISSING_164075B0 =
+      "Tablet Schema \u7f3a\u5931";
+  public static final String EXCEPTION_TABLET_VALUE_COLUMNS_ARE_INCOMPLETE_DCD09F3C =
+      "Tablet \u503c\u5217\u4e0d\u5b8c\u6574";
+  public static final String EXCEPTION_FAILED_TO_EVALUATE_A_TABLET_ROW_7E4E94CE =
+      "Tablet \u884c\u6c42\u503c\u5931\u8d25";
+  public static final String EXCEPTION_TABLE_BINDING_IS_NOT_AVAILABLE_882C5F2F =
+      "\u8868\u7ed1\u5b9a\u4e0d\u53ef\u7528";
+  public static final String EXCEPTION_REFERENCED_COLUMN_IS_NOT_A_TAG_COLUMN_33FA34BC =
+      "\u5f15\u7528\u7684\u5217\u4e0d\u662f TAG \u5217";
+  public static final String EXCEPTION_REFERENCED_COLUMN_CATEGORY_CHANGED_564594D8 =
+      "\u5f15\u7528\u5217\u7684\u7c7b\u522b\u5df2\u53d8\u66f4";
+  public static final String EXCEPTION_REFERENCED_TAG_COLUMN_IS_MISSING_55E07377 =
+      "\u5f15\u7528\u7684 TAG \u5217\u7f3a\u5931";
+  public static final String EXCEPTION_TABLET_MEASUREMENT_SCHEMA_IS_INCOMPLETE_6A813472 =
+      "Tablet \u6d4b\u91cf\u7ed3\u6784\u4e0d\u5b8c\u6574";
+  public static final String EXCEPTION_FAILED_TO_COMPACT_FILTERED_TABLET_5D4AD8AA =
+      "\u538b\u7f29\u8fc7\u6ee4\u540e\u7684 Tablet \u5931\u8d25";
+  public static final String EXCEPTION_TABLET_COLUMN_CATEGORIES_ARE_MISSING_2C660532 =
+      "Tablet \u5217\u7c7b\u522b\u7f3a\u5931";
+  public static final String EXCEPTION_TABLET_COLUMN_CATEGORY_IS_MISSING_A812B500 =
+      "Tablet \u5217\u7c7b\u522b\u7f3a\u5931";
+  public static final String EXCEPTION_TABLET_TIMESTAMPS_ARE_INCOMPLETE_24F8CE6F =
+      "Tablet \u65f6\u95f4\u6233\u4e0d\u5b8c\u6574";
+  public static final String EXCEPTION_TABLET_BITMAPS_ARE_INCOMPLETE_7BBC8035 =
+      "Tablet bitmap \u4e0d\u5b8c\u6574";
+  public static final String EXCEPTION_TABLET_VALUE_COLUMN_IS_INCOMPLETE_845721FE =
+      "Tablet \u503c\u5217\u4e0d\u5b8c\u6574";
+  public static final String EXCEPTION_TREE_VIEW_PROJECTOR_IS_UNAVAILABLE_FOR_FILTERED_SUBSCRIPTION_DATA_B5F396A5 =
+      "\u8fc7\u6ee4\u8ba2\u9605\u6570\u636e\u7684 Tree View \u6295\u5f71\u5668\u4e0d\u53ef\u7528";
+  public static final String
+      EXCEPTION_TOPIC_CONFIGURATION_IS_NOT_AVAILABLE_FOR_TAG_FILTER_1E023E3A =
+          "\u65e0\u6cd5\u83b7\u53d6 tag-filter \u7684 Topic \u914d\u7f6e";
+  public static final String
+      EXCEPTION_TOPIC_CONFIGURATION_CHANGED_WHILE_CAPTURING_TAG_FILTER_SNAPSHOT_984CEB1B =
+          "\u6355\u83b7 tag-filter \u5feb\u7167\u65f6 Topic \u914d\u7f6e\u53d1\u751f\u53d8\u5316";
+  public static final String
+      LOG_SUBSCRIPTION_TABLE_SCHEMA_IS_NOT_AVAILABLE_FOR_TAG_FILTER_TOPIC_ARG_E864C98F =
+      "\u8ba2\u9605:tag-filter Topic \u7684\u8868 Schema \u4e0d\u53ef\u7528 [{}]";
 
   public static final String CREATE_NEW_REGION_ERROR_FMT = "创建新 region %s 错误,异常:%s";
   public static final String CREATE_NEW_REGION_SUCCEED_FMT = "创建新 region %s 成功";
@@ -793,6 +873,14 @@
       "\u8ba2\u9605\uff1a\u61d2\u52a0\u8f7d\u5237\u65b0\u4e3b\u9898 [{}] \u7684 column-filter \u5339\u914d\u5668\u5931\u8d25";
   public static final String SUBSCRIPTION_DROP_COLUMN_FILTER =
       "\u8ba2\u9605\uff1a\u5220\u9664\u4e3b\u9898 [{}] \u7684 column-filter \u5339\u914d\u5668";
+  public static final String
+      LOG_SUBSCRIPTION_FAILED_TO_REFRESH_TAG_FILTER_MATCHER_FOR_TOPIC_ARG_USE_EMPTY_MATCHER_TO_FAIL_CLOSED_3CB76D70 =
+          "\u8ba2\u9605\uff1a\u5237\u65b0\u4e3b\u9898 [{}] \u7684 tag-filter \u5339\u914d\u5668\u5931\u8d25\uff0c\u4f7f\u7528\u7a7a\u5339\u914d\u5668\u4ee5\u5b89\u5168\u5931\u8d25";
+  public static final String
+      LOG_SUBSCRIPTION_REFRESHED_TAG_FILTER_MATCHER_FOR_TOPIC_ARG_71598849 =
+          "\u8ba2\u9605\uff1a\u5df2\u5237\u65b0\u4e3b\u9898 [{}] \u7684 tag-filter \u5339\u914d\u5668";
+  public static final String LOG_SUBSCRIPTION_DROPPED_TAG_FILTER_MATCHER_FOR_TOPIC_ARG_9EA52458 =
+      "\u8ba2\u9605\uff1a\u5220\u9664\u4e3b\u9898 [{}] \u7684 tag-filter \u5339\u914d\u5668";
   public static final String SUBSCRIPTION_UNSUPPORTED_CONSENSUS_PROGRESS_FILE_VERSION_FMT =
       "不支持的共识订阅进度文件版本 %s";
 
@@ -833,6 +921,8 @@
       "从批次 {} 封存事件时发生异常";
   public static final String EXCEPTION_CONSTRUCT_NEW_BATCH =
       "构造新批次时发生异常";
+  public static final String EXCEPTION_FAILED_TO_SEAL_SUBSCRIPTION_EVENT_BATCH_1FB7E92C =
+      "\u5c01\u5b58\u8ba2\u9605\u4e8b\u4ef6\u6279\u6b21\u5931\u8d25";
 
   // ---------------------------------------------------------------------------
   // subscription – SubscriptionPrefetchingQueue
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 203c755..6a50a25 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
@@ -4810,6 +4810,12 @@
           "没有可用于执行 DeviceEntry 内存限制的 materializer";
   public static final String EXCEPTION_NO_MORE_DEVICEENTRY_RECORDS_ARE_AVAILABLE_8D51C199 =
       "没有更多可用的 DeviceEntry 记录";
+  public static final String
+      EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_DUPLICATE_ARG_ATTRIBUTES_ARE_NOT_ALLOWED_27315578 =
+          "\u521b\u5efa\u6216\u4fee\u6539 Topic \u5931\u8d25\uff0c\u4e0d\u5141\u8bb8\u91cd\u590d\u7684 %s \u5c5e\u6027";
+  public static final String
+      EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_ARG_IS_ONLY_SUPPORTED_FOR_TABLE_TOPICS_A5126607 =
+          "\u521b\u5efa\u6216\u4fee\u6539 Topic \u5931\u8d25\uff0c%s \u4ec5\u652f\u6301\u8868\u6a21\u578b Topic";
   public static final String EXCEPTION_ONLY_INMEMORYDEVICEENTRYDATASET_SUPPORTS_GET_INLINE_DEVICE_ENTRIES_07A52CAB =
       "只有 InMemoryDeviceEntryDataSet 支持获取内存中的设备条目";
   public static final String EXCEPTION_LOAD_CONSENSUS_INVALID_PIECE_REF_F3498507 =
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/event/common/PipeInsertionEvent.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/event/common/PipeInsertionEvent.java
index bab43ce..8f45510 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/event/common/PipeInsertionEvent.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/event/common/PipeInsertionEvent.java
@@ -169,6 +169,9 @@
   }
 
   public String getTableModelDatabaseName() {
+    if (!isTableModelEvent()) {
+      return null;
+    }
     return tableModelDatabaseName == null
         ? tableModelDatabaseName = PathUtils.unQualifyDatabaseName(sourceDatabaseNameFromDataRegion)
         : tableModelDatabaseName;
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/sink/payload/evolvable/batch/PipeTabletEventTsFileBatch.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/sink/payload/evolvable/batch/PipeTabletEventTsFileBatch.java
index 4793f0c..5ca1862 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/sink/payload/evolvable/batch/PipeTabletEventTsFileBatch.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/sink/payload/evolvable/batch/PipeTabletEventTsFileBatch.java
@@ -46,6 +46,7 @@
 import java.util.Objects;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.function.BiFunction;
+import java.util.stream.Collectors;
 
 import static org.apache.iotdb.db.pipe.event.common.tablet.PipeRawTabletInsertionEvent.isTabletEmpty;
 
@@ -58,26 +59,33 @@
 
   private final PipeTsFileBuilder treeModeTsFileBuilder;
   private final PipeTsFileBuilder tableModeTsFileBuilder;
-  private final BiFunction<String, Tablet, Tablet> tableModelTabletPruner;
+  private final TabletTransformer tabletTransformer;
 
   private final Map<Pair<String, Long>, Double> pipeName2WeightMap = new HashMap<>();
 
   public PipeTabletEventTsFileBatch(final int maxDelayInMs, final long requestMaxBatchSizeInBytes) {
-    this(maxDelayInMs, requestMaxBatchSizeInBytes, null, null);
+    this(maxDelayInMs, requestMaxBatchSizeInBytes, null, null, null);
   }
 
   public PipeTabletEventTsFileBatch(
       final int maxDelayInMs,
       final long requestMaxBatchSizeInBytes,
       final TriLongConsumer recordMetric) {
-    this(maxDelayInMs, requestMaxBatchSizeInBytes, recordMetric, null);
+    this(maxDelayInMs, requestMaxBatchSizeInBytes, recordMetric, null, null);
   }
 
   public PipeTabletEventTsFileBatch(
       final int maxDelayInMs,
       final long requestMaxBatchSizeInBytes,
       final BiFunction<String, Tablet, Tablet> tableModelTabletPruner) {
-    this(maxDelayInMs, requestMaxBatchSizeInBytes, null, tableModelTabletPruner);
+    this(maxDelayInMs, requestMaxBatchSizeInBytes, null, tableModelTabletPruner, null);
+  }
+
+  public PipeTabletEventTsFileBatch(
+      final int maxDelayInMs,
+      final long requestMaxBatchSizeInBytes,
+      final TabletTransformer tabletTransformer) {
+    this(maxDelayInMs, requestMaxBatchSizeInBytes, null, null, tabletTransformer);
   }
 
   public PipeTabletEventTsFileBatch(
@@ -85,12 +93,31 @@
       final long requestMaxBatchSizeInBytes,
       final TriLongConsumer recordMetric,
       final BiFunction<String, Tablet, Tablet> tableModelTabletPruner) {
+    this(maxDelayInMs, requestMaxBatchSizeInBytes, recordMetric, tableModelTabletPruner, null);
+  }
+
+  private PipeTabletEventTsFileBatch(
+      final int maxDelayInMs,
+      final long requestMaxBatchSizeInBytes,
+      final TriLongConsumer recordMetric,
+      final BiFunction<String, Tablet, Tablet> tableModelTabletPruner,
+      final TabletTransformer tabletTransformer) {
     super(maxDelayInMs, requestMaxBatchSizeInBytes, recordMetric);
 
     final AtomicLong tsFileIdGenerator = new AtomicLong(0);
     treeModeTsFileBuilder = new PipeTreeModelTsFileBuilderV2(currentBatchId, tsFileIdGenerator);
     tableModeTsFileBuilder = new PipeTableModelTsFileBuilderV2(currentBatchId, tsFileIdGenerator);
-    this.tableModelTabletPruner = tableModelTabletPruner;
+    this.tabletTransformer =
+        Objects.nonNull(tabletTransformer)
+            ? tabletTransformer
+            : (databaseName, tablet, isTableModel, isAligned) ->
+                new TabletTransformResult(
+                    Objects.nonNull(tableModelTabletPruner) && isTableModel
+                        ? tableModelTabletPruner.apply(databaseName, tablet)
+                        : tablet,
+                    databaseName,
+                    isTableModel,
+                    isAligned);
   }
 
   @Override
@@ -100,25 +127,22 @@
           (PipeInsertNodeTabletInsertionEvent) event;
       final boolean isTableModel = insertNodeTabletInsertionEvent.isTableModelEvent();
       final List<Tablet> tablets = insertNodeTabletInsertionEvent.convertToTablets();
-      final List<Tablet> retainedTablets = new ArrayList<>(tablets.size());
-      final List<Boolean> retainedAlignedFlags = new ArrayList<>(tablets.size());
+      final List<TabletTransformResult> retainedTablets = new ArrayList<>(tablets.size());
       for (int i = 0; i < tablets.size(); ++i) {
-        Tablet tablet = tablets.get(i);
+        final Tablet tablet = tablets.get(i);
         if (isTabletEmpty(tablet)) {
           continue;
         }
-        if (isTableModel) {
-          tablet =
-              pruneTableModelTablet(
-                  tablet, insertNodeTabletInsertionEvent.getTableModelDatabaseName());
-          if (isTabletEmpty(tablet)) {
-            continue;
-          }
+        final TabletTransformResult transformedTablet =
+            transformTablet(
+                insertNodeTabletInsertionEvent.getTableModelDatabaseName(),
+                tablet,
+                isTableModel,
+                isTableModel || insertNodeTabletInsertionEvent.isAligned(i));
+        if (Objects.isNull(transformedTablet) || isTabletEmpty(transformedTablet.getTablet())) {
+          continue;
         }
-        retainedTablets.add(tablet);
-        if (!isTableModel) {
-          retainedAlignedFlags.add(insertNodeTabletInsertionEvent.isAligned(i));
-        }
+        retainedTablets.add(transformedTablet);
       }
 
       // Pruning can remove all rows/columns from a tablet.  Account only for data that is
@@ -127,50 +151,58 @@
       if (retainedTablets.isEmpty()) {
         return false;
       }
-      increaseTotalBufferSizeAndUpdateMemoryBlock(calculateTabletsSizeInBytes(retainedTablets));
-      for (int i = 0; i < retainedTablets.size(); ++i) {
-        final Tablet tablet = retainedTablets.get(i);
-        if (isTableModel) {
+      increaseTotalBufferSizeAndUpdateMemoryBlock(
+          calculateTabletsSizeInBytes(
+              retainedTablets.stream()
+                  .map(TabletTransformResult::getTablet)
+                  .collect(Collectors.toList())));
+      for (final TabletTransformResult transformedTablet : retainedTablets) {
+        if (transformedTablet.isTableModel()) {
           bufferTableModelTablet(
               insertNodeTabletInsertionEvent.getPipeName(),
               insertNodeTabletInsertionEvent.getCreationTime(),
-              tablet,
-              insertNodeTabletInsertionEvent.getTableModelDatabaseName());
+              transformedTablet.getTablet(),
+              transformedTablet.getDatabaseName());
         } else {
           bufferTreeModelTablet(
               insertNodeTabletInsertionEvent.getPipeName(),
               insertNodeTabletInsertionEvent.getCreationTime(),
-              tablet,
-              retainedAlignedFlags.get(i));
+              transformedTablet.getTablet(),
+              transformedTablet.isAligned());
         }
       }
       return true;
     } else if (event instanceof PipeRawTabletInsertionEvent) {
       final PipeRawTabletInsertionEvent rawTabletInsertionEvent =
           (PipeRawTabletInsertionEvent) event;
-      Tablet tablet = rawTabletInsertionEvent.convertToTablet();
+      final Tablet tablet = rawTabletInsertionEvent.convertToTablet();
       if (isTabletEmpty(tablet)) {
         return false;
       }
-      if (rawTabletInsertionEvent.isTableModelEvent()) {
-        tablet = pruneTableModelTablet(tablet, rawTabletInsertionEvent.getTableModelDatabaseName());
-        if (isTabletEmpty(tablet)) {
-          return false;
-        }
+      final boolean isTableModel = rawTabletInsertionEvent.isTableModelEvent();
+      final TabletTransformResult transformedTablet =
+          transformTablet(
+              rawTabletInsertionEvent.getTableModelDatabaseName(),
+              tablet,
+              isTableModel,
+              isTableModel || rawTabletInsertionEvent.isAligned());
+      if (Objects.isNull(transformedTablet) || isTabletEmpty(transformedTablet.getTablet())) {
+        return false;
       }
-      increaseTotalBufferSizeAndUpdateMemoryBlock(calculateTabletSizeInBytes(tablet));
-      if (rawTabletInsertionEvent.isTableModelEvent()) {
+      increaseTotalBufferSizeAndUpdateMemoryBlock(
+          calculateTabletSizeInBytes(transformedTablet.getTablet()));
+      if (transformedTablet.isTableModel()) {
         bufferTableModelTablet(
             rawTabletInsertionEvent.getPipeName(),
             rawTabletInsertionEvent.getCreationTime(),
-            tablet,
-            rawTabletInsertionEvent.getTableModelDatabaseName());
+            transformedTablet.getTablet(),
+            transformedTablet.getDatabaseName());
       } else {
         bufferTreeModelTablet(
             rawTabletInsertionEvent.getPipeName(),
             rawTabletInsertionEvent.getCreationTime(),
-            tablet,
-            rawTabletInsertionEvent.isAligned());
+            transformedTablet.getTablet(),
+            transformedTablet.isAligned());
       }
       return true;
     } else {
@@ -183,10 +215,12 @@
     return false;
   }
 
-  private Tablet pruneTableModelTablet(final Tablet tablet, final String databaseName) {
-    return Objects.nonNull(tableModelTabletPruner)
-        ? tableModelTabletPruner.apply(databaseName, tablet)
-        : tablet;
+  private TabletTransformResult transformTablet(
+      final String databaseName,
+      final Tablet tablet,
+      final boolean isTableModel,
+      final boolean isAligned) {
+    return tabletTransformer.transform(databaseName, tablet, isTableModel, isAligned);
   }
 
   private long calculateTabletsSizeInBytes(final List<Tablet> tablets) {
@@ -269,6 +303,48 @@
     return new HashMap<>(pipeName2WeightMap);
   }
 
+  @FunctionalInterface
+  public interface TabletTransformer {
+
+    TabletTransformResult transform(
+        String databaseName, Tablet tablet, boolean isTableModel, boolean isAligned);
+  }
+
+  public static final class TabletTransformResult {
+
+    private final Tablet tablet;
+    private final String databaseName;
+    private final boolean tableModel;
+    private final boolean aligned;
+
+    public TabletTransformResult(
+        final Tablet tablet,
+        final String databaseName,
+        final boolean tableModel,
+        final boolean aligned) {
+      this.tablet = tablet;
+      this.databaseName = databaseName;
+      this.tableModel = tableModel;
+      this.aligned = aligned;
+    }
+
+    public Tablet getTablet() {
+      return tablet;
+    }
+
+    public String getDatabaseName() {
+      return databaseName;
+    }
+
+    public boolean isTableModel() {
+      return tableModel;
+    }
+
+    public boolean isAligned() {
+      return aligned;
+    }
+  }
+
   /**
    * Converts a Tablet to a TSFile and returns the generated TSFile along with its corresponding
    * database name.
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/common/header/DatasetHeaderFactory.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/common/header/DatasetHeaderFactory.java
index e623f81..671c817 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/common/header/DatasetHeaderFactory.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/common/header/DatasetHeaderFactory.java
@@ -178,6 +178,10 @@
     return new DatasetHeader(ColumnHeaderConstant.showTopicColumnHeaders, true);
   }
 
+  public static DatasetHeader getShowTableTopicHeader() {
+    return new DatasetHeader(ColumnHeaderConstant.showTableTopicColumnHeaders, true);
+  }
+
   public static DatasetHeader getShowSubscriptionHeader() {
     return new DatasetHeader(ColumnHeaderConstant.showSubscriptionColumnHeaders, true);
   }
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 f91969a..033ba02 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
@@ -268,6 +268,7 @@
 import org.apache.iotdb.db.queryengine.plan.statement.sys.StopRepairDataStatement;
 import org.apache.iotdb.db.schemaengine.table.DataNodeTableCache;
 import org.apache.iotdb.db.subscription.columnfilter.ColumnFilterParser;
+import org.apache.iotdb.db.subscription.tagfilter.TagFilterParser;
 import org.apache.iotdb.pipe.api.customizer.parameter.PipeParameters;
 import org.apache.iotdb.rpc.TSStatusCode;
 import org.apache.iotdb.rpc.subscription.config.TopicConstant;
@@ -315,6 +316,7 @@
   public static final String DATABASE_NOT_SPECIFIED = "database is not specified";
 
   private static final ColumnFilterParser COLUMN_FILTER_PARSER = new ColumnFilterParser();
+  private static final TagFilterParser TAG_FILTER_PARSER = new TagFilterParser();
 
   private final IClientSession clientSession;
 
@@ -1650,6 +1652,7 @@
     node.getTopicAttributes()
         .put(SystemConstant.SQL_DIALECT_KEY, SystemConstant.SQL_DIALECT_TABLE_VALUE);
     validateAndNormalizeColumnFilter(node.getTopicAttributes());
+    validateAndNormalizeTagFilter(node.getTopicAttributes());
 
     return new CreateTopicTask(node);
   }
@@ -1662,44 +1665,65 @@
     node.getTopicAttributes()
         .put(SystemConstant.SQL_DIALECT_KEY, SystemConstant.SQL_DIALECT_TABLE_VALUE);
     validateAndNormalizeColumnFilter(node.getTopicAttributes());
+    validateAndNormalizeTagFilter(node.getTopicAttributes());
 
     return new AlterTopicTask(node);
   }
 
   private static void validateAndNormalizeColumnFilter(final Map<String, String> topicAttributes) {
-    String columnFilterKey = null;
-    String columnFilter = null;
-    boolean hasColumnFilter = false;
+    validateAndNormalizeSubscriptionFilter(
+        topicAttributes, TopicConstant.COLUMN_FILTER_KEY, COLUMN_FILTER_PARSER::parseAndValidate);
+  }
+
+  private static void validateAndNormalizeTagFilter(final Map<String, String> topicAttributes) {
+    validateAndNormalizeSubscriptionFilter(
+        topicAttributes, TopicConstant.TAG_FILTER_KEY, TAG_FILTER_PARSER::parseAndValidate);
+  }
+
+  private static void validateAndNormalizeSubscriptionFilter(
+      final Map<String, String> topicAttributes,
+      final String expectedKey,
+      final SubscriptionFilterValidator validator) {
+    String filterKey = null;
+    String filter = null;
+    boolean hasFilter = false;
     for (final Map.Entry<String, String> entry : topicAttributes.entrySet()) {
-      if (TopicConstant.COLUMN_FILTER_KEY.equalsIgnoreCase(entry.getKey())) {
-        if (hasColumnFilter) {
+      if (expectedKey.equalsIgnoreCase(entry.getKey())) {
+        if (hasFilter) {
           throw new SemanticException(
               String.format(
-                  "Failed to create or alter topic, duplicate %s attributes are not allowed",
-                  TopicConstant.COLUMN_FILTER_KEY));
+                  DataNodeQueryMessages
+                      .EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_DUPLICATE_ARG_ATTRIBUTES_ARE_NOT_ALLOWED_27315578,
+                  expectedKey));
         }
-        hasColumnFilter = true;
-        columnFilterKey = entry.getKey();
-        columnFilter = entry.getValue();
+        hasFilter = true;
+        filterKey = entry.getKey();
+        filter = entry.getValue();
       }
     }
 
-    if (!hasColumnFilter) {
+    if (!hasFilter) {
       return;
     }
 
-    if (!TopicConstant.COLUMN_FILTER_KEY.equals(columnFilterKey)) {
-      topicAttributes.remove(columnFilterKey);
-      topicAttributes.put(TopicConstant.COLUMN_FILTER_KEY, columnFilter);
+    if (!expectedKey.equals(filterKey)) {
+      topicAttributes.remove(filterKey);
+      topicAttributes.put(expectedKey, filter);
     }
 
     try {
-      COLUMN_FILTER_PARSER.parseAndValidate(columnFilter);
+      validator.validate(filter);
     } catch (final SubscriptionException e) {
       throw new SemanticException(e.getMessage());
     }
   }
 
+  @FunctionalInterface
+  private interface SubscriptionFilterValidator {
+
+    void validate(String filter) throws SubscriptionException;
+  }
+
   @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 d1e363f..269db0c 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
@@ -783,7 +783,7 @@
     createTopicStatement
         .getTopicAttributes()
         .put(SystemConstant.SQL_DIALECT_KEY, SystemConstant.SQL_DIALECT_TREE_VALUE);
-    rejectColumnFilterForTreeTopic(createTopicStatement.getTopicAttributes());
+    rejectTableOnlyFiltersForTreeTopic(createTopicStatement.getTopicAttributes());
 
     return new CreateTopicTask(createTopicStatement);
   }
@@ -794,18 +794,21 @@
     alterTopicStatement
         .getTopicAttributes()
         .put(SystemConstant.SQL_DIALECT_KEY, SystemConstant.SQL_DIALECT_TREE_VALUE);
-    rejectColumnFilterForTreeTopic(alterTopicStatement.getTopicAttributes());
+    rejectTableOnlyFiltersForTreeTopic(alterTopicStatement.getTopicAttributes());
 
     return new AlterTopicTask(alterTopicStatement);
   }
 
-  private static void rejectColumnFilterForTreeTopic(final Map<String, String> topicAttributes) {
+  private static void rejectTableOnlyFiltersForTreeTopic(
+      final Map<String, String> topicAttributes) {
     for (final String key : topicAttributes.keySet()) {
-      if (TopicConstant.COLUMN_FILTER_KEY.equalsIgnoreCase(key)) {
+      if (TopicConstant.COLUMN_FILTER_KEY.equalsIgnoreCase(key)
+          || TopicConstant.TAG_FILTER_KEY.equalsIgnoreCase(key)) {
         throw new SemanticException(
             String.format(
-                "Failed to create or alter topic, %s is only supported for table topics",
-                TopicConstant.COLUMN_FILTER_KEY));
+                DataNodeQueryMessages
+                    .EXCEPTION_FAILED_TO_CREATE_OR_ALTER_TOPIC_ARG_IS_ONLY_SUPPORTED_FOR_TABLE_TOPICS_A5126607,
+                key));
       }
     }
   }
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 a995d64..251d3f9 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
@@ -3313,6 +3313,7 @@
           showTopicResp.isSetTopicInfoList()
               ? showTopicResp.getTopicInfoList()
               : Collections.emptyList(),
+          showTopicsStatement.isTableModel(),
           future);
     } catch (final Exception e) {
       future.setException(e);
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/sys/subscription/ShowTopicsTask.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/sys/subscription/ShowTopicsTask.java
index 1871ef6..e7ffe77 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/sys/subscription/ShowTopicsTask.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/config/sys/subscription/ShowTopicsTask.java
@@ -28,6 +28,8 @@
 import org.apache.iotdb.db.queryengine.plan.execution.config.executor.IConfigTaskExecutor;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ShowTopics;
 import org.apache.iotdb.db.queryengine.plan.statement.metadata.subscription.ShowTopicsStatement;
+import org.apache.iotdb.db.subscription.agent.SubscriptionAgent;
+import org.apache.iotdb.db.subscription.tagfilter.TagFilterMatcher;
 import org.apache.iotdb.rpc.TSStatusCode;
 
 import com.google.common.util.concurrent.ListenableFuture;
@@ -37,6 +39,7 @@
 import org.apache.tsfile.utils.Binary;
 
 import java.util.List;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 
 public class ShowTopicsTask implements IConfigTask {
@@ -60,12 +63,28 @@
   }
 
   public static void buildTSBlock(
-      final List<TShowTopicInfo> topicInfoList, final SettableFuture<ConfigTaskResult> future) {
+      final List<TShowTopicInfo> topicInfoList,
+      final boolean isTableModel,
+      final SettableFuture<ConfigTaskResult> future) {
+    buildTSBlock(
+        topicInfoList,
+        isTableModel,
+        topicName -> SubscriptionAgent.broker().getTagFilterMatcher(topicName, true),
+        future);
+  }
+
+  static void buildTSBlock(
+      final List<TShowTopicInfo> topicInfoList,
+      final boolean isTableModel,
+      final Function<String, TagFilterMatcher> matcherProvider,
+      final SettableFuture<ConfigTaskResult> future) {
+    final List<ColumnHeader> columnHeaders =
+        isTableModel
+            ? ColumnHeaderConstant.showTableTopicColumnHeaders
+            : ColumnHeaderConstant.showTopicColumnHeaders;
     final TsBlockBuilder builder =
         new TsBlockBuilder(
-            ColumnHeaderConstant.showTopicColumnHeaders.stream()
-                .map(ColumnHeader::getColumnType)
-                .collect(Collectors.toList()));
+            columnHeaders.stream().map(ColumnHeader::getColumnType).collect(Collectors.toList()));
 
     for (final TShowTopicInfo topicInfo : topicInfoList) {
       builder.getTimeColumnBuilder().writeLong(0L);
@@ -75,6 +94,17 @@
       builder
           .getColumnBuilder(1)
           .writeBinary(new Binary(topicInfo.getTopicAttributes(), TSFileConfig.STRING_CHARSET));
+      if (isTableModel) {
+        final TagFilterMatcher matcher;
+        try {
+          matcher = matcherProvider.apply(topicInfo.getTopicName());
+        } catch (final RuntimeException e) {
+          writeTagFilterDiagnostic(builder, TagFilterMatcher.failure(e));
+          builder.declarePosition();
+          continue;
+        }
+        writeTagFilterDiagnostic(builder, matcher);
+      }
       builder.declarePosition();
     }
 
@@ -82,6 +112,22 @@
         new ConfigTaskResult(
             TSStatusCode.SUCCESS_STATUS,
             builder.build(),
-            DatasetHeaderFactory.getShowTopicHeader()));
+            isTableModel
+                ? DatasetHeaderFactory.getShowTableTopicHeader()
+                : DatasetHeaderFactory.getShowTopicHeader()));
+  }
+
+  private static void writeTagFilterDiagnostic(
+      final TsBlockBuilder builder, final TagFilterMatcher matcher) {
+    builder
+        .getColumnBuilder(2)
+        .writeBinary(new Binary(matcher.getRuntimeStatus().name(), TSFileConfig.STRING_CHARSET));
+    if (matcher.isFailure()) {
+      builder
+          .getColumnBuilder(3)
+          .writeBinary(new Binary(matcher.getFailureMessage(), TSFileConfig.STRING_CHARSET));
+    } else {
+      builder.getColumnBuilder(3).appendNull();
+    }
   }
 }
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 63e94c2..2f8f99e 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
@@ -793,7 +793,7 @@
     if (node.hasIfNotExistsCondition()) {
       builder.append("IF NOT EXISTS ");
     }
-    builder.append(node.getTopicName());
+    builder.append(ShowCreateTableTask.getIdentifier(node.getTopicName()));
     builder.append(" \n");
 
     if (!node.getTopicAttributes().isEmpty()) {
@@ -805,11 +805,9 @@
                   .map(
                       entry ->
                           indentString(1)
-                              + "\""
-                              + entry.getKey()
-                              + "\" = \""
-                              + entry.getValue()
-                              + "\"")
+                              + ShowCreateTableTask.getString(entry.getKey())
+                              + " = "
+                              + ShowCreateTableTask.getString(entry.getValue()))
                   .collect(joining(", " + "\n")))
           .append(")\n");
     }
@@ -820,7 +818,7 @@
   @Override
   public Void visitAlterTopic(AlterTopic node, Integer context) {
     builder.append("ALTER TOPIC ");
-    builder.append(node.getTopicName());
+    builder.append(ShowCreateTableTask.getIdentifier(node.getTopicName()));
     builder.append(" \n");
 
     builder
@@ -831,11 +829,9 @@
                 .map(
                     entry ->
                         indentString(1)
-                            + "\""
-                            + entry.getKey()
-                            + "\" = \""
-                            + entry.getValue()
-                            + "\"")
+                            + ShowCreateTableTask.getString(entry.getKey())
+                            + " = "
+                            + ShowCreateTableTask.getString(entry.getValue()))
                 .collect(joining(", " + "\n")))
         .append(")\n");
 
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionBrokerAgent.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionBrokerAgent.java
index b5bd049..89d8776 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionBrokerAgent.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/agent/SubscriptionBrokerAgent.java
@@ -20,6 +20,7 @@
 package org.apache.iotdb.db.subscription.agent;
 
 import org.apache.iotdb.commons.consensus.ConsensusGroupId;
+import org.apache.iotdb.commons.schema.table.TsTable;
 import org.apache.iotdb.commons.subscription.meta.consumer.SubscriptionProgressSnapshot;
 import org.apache.iotdb.consensus.ConsensusFactory;
 import org.apache.iotdb.consensus.IConsensus;
@@ -31,6 +32,7 @@
 import org.apache.iotdb.db.i18n.DataNodeMiscMessages;
 import org.apache.iotdb.db.i18n.DataNodePipeMessages;
 import org.apache.iotdb.db.schemaengine.table.DataNodeTableCache;
+import org.apache.iotdb.db.schemaengine.table.ITableCache;
 import org.apache.iotdb.db.subscription.broker.ConsensusSubscriptionBroker;
 import org.apache.iotdb.db.subscription.broker.ISubscriptionBroker;
 import org.apache.iotdb.db.subscription.broker.SubscriptionBroker;
@@ -42,10 +44,12 @@
 import org.apache.iotdb.db.subscription.columnfilter.ColumnFilterMatcher;
 import org.apache.iotdb.db.subscription.event.SubscriptionEvent;
 import org.apache.iotdb.db.subscription.resource.SubscriptionDataNodeResourceManager;
+import org.apache.iotdb.db.subscription.tagfilter.TagFilterMatcher;
 import org.apache.iotdb.db.subscription.task.execution.ConsensusSubscriptionPrefetchExecutorManager;
 import org.apache.iotdb.db.subscription.task.subtask.SubscriptionSinkSubtask;
 import org.apache.iotdb.rpc.subscription.config.ConsumerConfig;
 import org.apache.iotdb.rpc.subscription.config.TopicConfig;
+import org.apache.iotdb.rpc.subscription.config.TopicConstant;
 import org.apache.iotdb.rpc.subscription.exception.SubscriptionException;
 import org.apache.iotdb.rpc.subscription.payload.poll.RegionProgress;
 import org.apache.iotdb.rpc.subscription.payload.poll.SubscriptionCommitContext;
@@ -77,6 +81,7 @@
 
   private static final ColumnFilterMatcher EMPTY_COLUMN_FILTER_MATCHER =
       ColumnFilterMatcher.ofSelectedColumnNames(Collections.emptySet());
+  private static final int TAG_FILTER_SCHEMA_SNAPSHOT_MAX_RETRIES = 3;
 
   /** Subscription brokers grouped by consumer group. */
   private final Map<String, List<ISubscriptionBroker>> consumerGroupIdToBrokers =
@@ -87,6 +92,9 @@
 
   private final Map<String, ColumnFilterMatcher> topicNameToColumnFilterMatcher =
       new ConcurrentHashMap<>();
+  private final Map<String, TagFilterMatcher> topicNameToTagFilterMatcher =
+      new ConcurrentHashMap<>();
+  private final Map<String, Long> topicNameToTagFilterMatcherVersion = new ConcurrentHashMap<>();
 
   //////////////////////////// provided for subscription agent ////////////////////////////
 
@@ -787,6 +795,157 @@
     LOGGER.info(DataNodeMiscMessages.SUBSCRIPTION_DROP_COLUMN_FILTER, topicName);
   }
 
+  public void refreshTagFilter(final String topicName, final TopicConfig topicConfig) {
+    final ITableCache tableCache = DataNodeTableCache.getInstance();
+    if (Objects.isNull(topicConfig)
+        || !topicConfig.isTableTopic()
+        || topicConfig.isTagFilterTrivial()) {
+      cacheTagFilterMatcher(
+          topicName, TagFilterMatcher.matchAll(), tableCache.getInstanceVersion());
+      return;
+    }
+
+    for (int attempt = 0; attempt < TAG_FILTER_SCHEMA_SNAPSHOT_MAX_RETRIES; attempt++) {
+      final long versionBeforeBinding = tableCache.getInstanceVersion();
+      final TagFilterMatcher matcher;
+      try {
+        final Map<String, Map<String, TsTable>> bindingTables =
+            getTagFilterBindingTables(topicConfig);
+        matcher = TagFilterMatcher.fromTopicConfig(topicConfig, bindingTables);
+      } catch (final Exception e) {
+        if (versionBeforeBinding != tableCache.getInstanceVersion()) {
+          continue;
+        }
+        LOGGER.warn(
+            DataNodeMiscMessages
+                .LOG_SUBSCRIPTION_FAILED_TO_REFRESH_TAG_FILTER_MATCHER_FOR_TOPIC_ARG_USE_EMPTY_MATCHER_TO_FAIL_CLOSED_3CB76D70,
+            topicName,
+            e);
+        cacheTagFilterMatcher(topicName, TagFilterMatcher.failure(e), versionBeforeBinding);
+        return;
+      }
+
+      if (versionBeforeBinding != tableCache.getInstanceVersion()) {
+        continue;
+      }
+      cacheTagFilterMatcher(topicName, matcher, versionBeforeBinding);
+      LOGGER.info(
+          DataNodeMiscMessages.LOG_SUBSCRIPTION_REFRESHED_TAG_FILTER_MATCHER_FOR_TOPIC_ARG_71598849,
+          topicName);
+      return;
+    }
+
+    // Leave the matcher uncached after repeated schema changes. The caller fails closed for this
+    // attempt and retries against a fresh schema snapshot on the next access.
+    topicNameToTagFilterMatcher.remove(topicName);
+    topicNameToTagFilterMatcherVersion.remove(topicName);
+  }
+
+  private void cacheTagFilterMatcher(
+      final String topicName, final TagFilterMatcher matcher, final long tableCacheVersion) {
+    topicNameToTagFilterMatcher.put(topicName, matcher);
+    topicNameToTagFilterMatcherVersion.put(topicName, tableCacheVersion);
+  }
+
+  public TagFilterMatcher getTagFilterMatcher(final String topicName) {
+    return getTagFilterMatcher(topicName, true);
+  }
+
+  public TagFilterMatcher getTagFilterMatcher(final String topicName, final boolean isTableModel) {
+    if (!isTableModel) {
+      return TagFilterMatcher.matchAll();
+    }
+
+    final TagFilterMatcher matcher = topicNameToTagFilterMatcher.get(topicName);
+    final long tableCacheVersion = DataNodeTableCache.getInstance().getInstanceVersion();
+    if (Objects.nonNull(matcher)
+        && Objects.equals(topicNameToTagFilterMatcherVersion.get(topicName), tableCacheVersion)) {
+      return matcher;
+    }
+    if (Objects.nonNull(matcher)) {
+      topicNameToTagFilterMatcher.remove(topicName, matcher);
+      topicNameToTagFilterMatcherVersion.remove(topicName);
+    }
+
+    final TopicConfig topicConfig = SubscriptionAgent.topic().getTopicConfig(topicName, true);
+    if (Objects.isNull(topicConfig)) {
+      return TagFilterMatcher.failure(
+          new SubscriptionException(
+              DataNodeMiscMessages
+                  .EXCEPTION_TOPIC_CONFIGURATION_IS_NOT_AVAILABLE_FOR_TAG_FILTER_1E023E3A));
+    }
+
+    refreshTagFilter(topicName, topicConfig);
+    return topicNameToTagFilterMatcher.getOrDefault(
+        topicName, getDefaultTagFilterMatcher(topicConfig));
+  }
+
+  private static TagFilterMatcher getDefaultTagFilterMatcher(final TopicConfig topicConfig) {
+    return Objects.nonNull(topicConfig)
+            && topicConfig.isTableTopic()
+            && !topicConfig.isTagFilterTrivial()
+        ? TagFilterMatcher.failure(
+            new SubscriptionException(
+                String.format(
+                    DataNodeMiscMessages
+                        .EXCEPTION_TABLE_SCHEMA_KEPT_CHANGING_WHILE_BINDING_TAG_FILTER_FOR_DATABASE_PATTERN_ARG_AND_TABLE_PATTERN_ARG_RETRY_AFTER_SCHEMA_SYNCHRONIZATION_77A005E9,
+                    topicConfig.getStringOrDefault(
+                        TopicConstant.DATABASE_KEY, TopicConstant.DATABASE_DEFAULT_VALUE),
+                    topicConfig.getStringOrDefault(
+                        TopicConstant.TABLE_KEY, TopicConstant.TABLE_DEFAULT_VALUE))))
+        : TagFilterMatcher.matchAll();
+  }
+
+  private static Map<String, Map<String, TsTable>> getTagFilterBindingTables(
+      final TopicConfig topicConfig) {
+    if (Objects.isNull(topicConfig)
+        || !topicConfig.isTableTopic()
+        || topicConfig.isTagFilterTrivial()) {
+      return Collections.emptyMap();
+    }
+
+    final String database =
+        topicConfig.getStringOrDefault(
+            TopicConstant.DATABASE_KEY, TopicConstant.DATABASE_DEFAULT_VALUE);
+    final String tableName =
+        topicConfig.getStringOrDefault(TopicConstant.TABLE_KEY, TopicConstant.TABLE_DEFAULT_VALUE);
+    if (isDefaultTopicPattern(database, TopicConstant.DATABASE_DEFAULT_VALUE)
+        || isDefaultTopicPattern(tableName, TopicConstant.TABLE_DEFAULT_VALUE)
+        || !isLiteralTopicPattern(database)
+        || !isLiteralTopicPattern(tableName)) {
+      return DataNodeTableCache.getInstance().getTableSnapshot();
+    }
+
+    final TsTable table = DataNodeTableCache.getInstance().getTable(database, tableName, false);
+    if (Objects.isNull(table)) {
+      throw new SubscriptionException(
+          String.format(
+              DataNodeMiscMessages
+                  .EXCEPTION_TABLE_SCHEMA_ARG_ARG_IS_ABSENT_FROM_THIS_DATANODE_S_TABLE_CACHE_THE_TABLE_MAY_NOT_EXIST_OR_ITS_SCHEMA_HAS_NOT_BEEN_SYNCHRONIZED_YET_23975BDF,
+              database,
+              tableName));
+    }
+    return Collections.singletonMap(database, Collections.singletonMap(tableName, table));
+  }
+
+  private static boolean isLiteralTopicPattern(final String pattern) {
+    final String regexMetaCharacters = ".*+?[](){}\\|^$";
+    return Objects.nonNull(pattern)
+        && pattern.chars().noneMatch(c -> regexMetaCharacters.indexOf((char) c) >= 0);
+  }
+
+  private static boolean isDefaultTopicPattern(final String pattern, final String defaultPattern) {
+    return Objects.isNull(pattern) || defaultPattern.equals(pattern.trim());
+  }
+
+  public void dropTagFilter(final String topicName) {
+    topicNameToTagFilterMatcher.remove(topicName);
+    topicNameToTagFilterMatcherVersion.remove(topicName);
+    LOGGER.info(
+        DataNodeMiscMessages.LOG_SUBSCRIPTION_DROPPED_TAG_FILTER_MATCHER_FOR_TOPIC_ARG_9EA52458,
+        topicName);
+  }
+
   public void unbindConsensusPrefetchingQueue(
       final String consumerGroupId,
       final String topicName,
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 f68e60f..ef655aa 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
@@ -119,6 +119,9 @@
     final boolean isTableModel = metaFromCoordinator.visibleUnderTableModel();
     final TopicMeta oldMeta = topicMetaKeeper.getTopicMeta(topicName, isTableModel);
     TopicMeta.validateOwnerProgression(oldMeta, metaFromCoordinator);
+    metaFromCoordinator
+        .getConfig()
+        .inheritFilterVersion(Objects.isNull(oldMeta) ? null : oldMeta.getConfig());
     final Long recycleIntervalMs =
         metaFromCoordinator.getConfig().getRecycleUncommittedEventIntervalMs();
     topicMetaKeeper.removeTopicMeta(topicName, isTableModel);
@@ -133,11 +136,16 @@
     }
     if (shouldRefreshColumnFilter(oldMeta, metaFromCoordinator)) {
       SubscriptionAgent.broker().refreshColumnFilter(topicName, metaFromCoordinator.getConfig());
-    } else if (!metaFromCoordinator.getConfig().isTableTopic()
+    }
+    if (shouldRefreshTagFilter(oldMeta, metaFromCoordinator)) {
+      SubscriptionAgent.broker().refreshTagFilter(topicName, metaFromCoordinator.getConfig());
+    }
+    if (!metaFromCoordinator.getConfig().isTableTopic()
         && !topicMetaKeeper.containsTopicMeta(topicName, true)) {
       // ConfigNode rejects column-filter on tree topics. Drop defensively in case stale or replayed
       // topic metadata reaches this DataNode after a table-topic to tree-topic transition.
       SubscriptionAgent.broker().dropColumnFilter(topicName);
+      SubscriptionAgent.broker().dropTagFilter(topicName);
     }
     SubscriptionAgent.broker()
         .refreshConsensusQueueOrderMode(
@@ -175,6 +183,35 @@
                     newConfig, TopicConstant.TABLE_KEY, TopicConstant.TABLE_DEFAULT_VALUE)));
   }
 
+  static boolean shouldRefreshTagFilter(final TopicMeta oldMeta, final TopicMeta newMeta) {
+    if (Objects.isNull(newMeta) || !newMeta.getConfig().isTableTopic()) {
+      return false;
+    }
+    if (Objects.isNull(oldMeta) || !oldMeta.getConfig().isTableTopic()) {
+      return true;
+    }
+
+    final TopicConfig oldConfig = oldMeta.getConfig();
+    final TopicConfig newConfig = newMeta.getConfig();
+    return !Objects.equals(
+            trimFilterBindingValue(oldConfig.getTagFilter()),
+            trimFilterBindingValue(newConfig.getTagFilter()))
+        || !Objects.equals(
+            trimFilterBindingValue(
+                getAttributeIgnoreCase(
+                    oldConfig, TopicConstant.DATABASE_KEY, TopicConstant.DATABASE_DEFAULT_VALUE)),
+            trimFilterBindingValue(
+                getAttributeIgnoreCase(
+                    newConfig, TopicConstant.DATABASE_KEY, TopicConstant.DATABASE_DEFAULT_VALUE)))
+        || !Objects.equals(
+            trimFilterBindingValue(
+                getAttributeIgnoreCase(
+                    oldConfig, TopicConstant.TABLE_KEY, TopicConstant.TABLE_DEFAULT_VALUE)),
+            trimFilterBindingValue(
+                getAttributeIgnoreCase(
+                    newConfig, TopicConstant.TABLE_KEY, TopicConstant.TABLE_DEFAULT_VALUE)));
+  }
+
   private static String getAttributeIgnoreCase(
       final TopicConfig topicConfig, final String key, final String defaultValue) {
     return topicConfig.getAttribute().entrySet().stream()
@@ -189,6 +226,10 @@
     return Objects.nonNull(value) ? value.trim().toLowerCase(Locale.ROOT) : "";
   }
 
+  private static String trimFilterBindingValue(final String value) {
+    return Objects.nonNull(value) ? value.trim() : "";
+  }
+
   public TPushTopicMetaRespExceptionMessage handleTopicMetaChanges(
       final List<TopicMeta> topicMetasFromCoordinator) {
     acquireWriteLock();
@@ -271,6 +312,7 @@
     }
     if (Objects.nonNull(topicMeta) && topicMeta.visibleUnderTableModel()) {
       SubscriptionAgent.broker().dropColumnFilter(topicName);
+      SubscriptionAgent.broker().dropTagFilter(topicName);
     }
     if (Objects.nonNull(topicMeta) || Objects.nonNull(isTableModel)) {
       topicRecycleIntervalsMs.remove(
@@ -346,6 +388,16 @@
     return getTopicConfigs(topicNames, false);
   }
 
+  public TopicConfig getTopicConfig(final String topicName, final boolean isTableModel) {
+    acquireReadLock();
+    try {
+      final TopicMeta topicMeta = topicMetaKeeper.getTopicMeta(topicName, isTableModel);
+      return Objects.isNull(topicMeta) ? null : topicMeta.getConfig();
+    } finally {
+      releaseReadLock();
+    }
+  }
+
   public Map<String, TopicConfig> getTopicConfigs(
       final Set<String> topicNames, final boolean isTableModel) {
     acquireReadLock();
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/SubscriptionPrefetchingQueue.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/SubscriptionPrefetchingQueue.java
index 2756f4f..bd0b193 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/SubscriptionPrefetchingQueue.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/SubscriptionPrefetchingQueue.java
@@ -736,12 +736,11 @@
   }
 
   private boolean canPassThroughTsFile(final PipeTsFileInsertionEvent event) {
+    final boolean isTableModelSubscription = SubscriptionAgent.consumer().isTableModel(brokerId);
     return PipeEventCollector.canSkipParsing4TsFileEvent(event)
-        && (!event.isTableModelEvent()
-            || SubscriptionAgent.broker()
-                .getColumnFilterMatcher(
-                    topicName, SubscriptionAgent.consumer().isTableModel(brokerId))
-                .isMatchAll());
+        && (!isTableModelSubscription
+            || (SubscriptionAgent.broker().getColumnFilterMatcher(topicName, true).isMatchAll()
+                && SubscriptionAgent.broker().getTagFilterMatcher(topicName, true).isMatchAll()));
   }
 
   private RetryableState onRetryableTabletInsertionEvent(
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusLogToTabletConverter.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusLogToTabletConverter.java
index 1f132c8..c87d3d4 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusLogToTabletConverter.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusLogToTabletConverter.java
@@ -42,6 +42,8 @@
 import org.apache.iotdb.db.subscription.agent.SubscriptionAgent;
 import org.apache.iotdb.db.subscription.columnfilter.ColumnFilterMatcher;
 import org.apache.iotdb.db.subscription.columnfilter.TabletColumnPruner;
+import org.apache.iotdb.db.subscription.tagfilter.TabletTagFilter;
+import org.apache.iotdb.db.subscription.tagfilter.TagFilterMatcher;
 
 import org.apache.tsfile.enums.ColumnCategory;
 import org.apache.tsfile.enums.TSDataType;
@@ -71,6 +73,7 @@
   private final TablePattern tablePattern;
   private final String topicName;
   private final ColumnFilterMatcher fallbackColumnFilterMatcher;
+  private final TagFilterMatcher fallbackTagFilterMatcher;
 
   /**
    * The actual database name of the DataRegion this converter processes (table-model format without
@@ -83,7 +86,13 @@
       final TablePattern tablePattern,
       final ColumnFilterMatcher columnFilterMatcher,
       final String databaseName) {
-    this(treePattern, tablePattern, null, columnFilterMatcher, databaseName);
+    this(
+        treePattern,
+        tablePattern,
+        null,
+        columnFilterMatcher,
+        TagFilterMatcher.matchAll(),
+        databaseName);
   }
 
   public ConsensusLogToTabletConverter(
@@ -92,11 +101,38 @@
       final String topicName,
       final ColumnFilterMatcher columnFilterMatcher,
       final String databaseName) {
+    this(
+        treePattern,
+        tablePattern,
+        topicName,
+        columnFilterMatcher,
+        TagFilterMatcher.matchAll(),
+        databaseName);
+  }
+
+  public ConsensusLogToTabletConverter(
+      final TreePattern treePattern,
+      final TablePattern tablePattern,
+      final ColumnFilterMatcher columnFilterMatcher,
+      final TagFilterMatcher tagFilterMatcher,
+      final String databaseName) {
+    this(treePattern, tablePattern, null, columnFilterMatcher, tagFilterMatcher, databaseName);
+  }
+
+  public ConsensusLogToTabletConverter(
+      final TreePattern treePattern,
+      final TablePattern tablePattern,
+      final String topicName,
+      final ColumnFilterMatcher columnFilterMatcher,
+      final TagFilterMatcher tagFilterMatcher,
+      final String databaseName) {
     this.treePattern = treePattern;
     this.tablePattern = tablePattern;
     this.topicName = topicName;
     this.fallbackColumnFilterMatcher =
         Objects.nonNull(columnFilterMatcher) ? columnFilterMatcher : ColumnFilterMatcher.matchAll();
+    this.fallbackTagFilterMatcher =
+        Objects.nonNull(tagFilterMatcher) ? tagFilterMatcher : TagFilterMatcher.matchAll();
     this.databaseName = databaseName;
   }
 
@@ -104,16 +140,17 @@
     return databaseName;
   }
 
-  /** Freezes the column projection used by a delivered batch for later WAL replay. */
+  /** Freezes the column and TAG filters used by a delivered batch for later WAL replay. */
   ConsensusLogToTabletConverter snapshotForReplay() {
     return new ConsensusLogToTabletConverter(
-        treePattern, tablePattern, getColumnFilterMatcher(), databaseName);
+        treePattern, tablePattern, getColumnFilterMatcher(), getTagFilterMatcher(), databaseName);
   }
 
   boolean hasSameReplayProjection(final ConsensusLogToTabletConverter other) {
     return treePattern == other.treePattern
         && tablePattern == other.tablePattern
-        && fallbackColumnFilterMatcher == other.fallbackColumnFilterMatcher;
+        && fallbackColumnFilterMatcher == other.fallbackColumnFilterMatcher
+        && fallbackTagFilterMatcher == other.fallbackTagFilterMatcher;
   }
 
   boolean isTableModel() {
@@ -527,7 +564,10 @@
     }
 
     final Tablet prunedTablet =
-        TabletColumnPruner.pruneTableModelTablet(tablet, databaseName, getColumnFilterMatcher());
+        TabletColumnPruner.pruneTableModelTablet(
+            TabletTagFilter.filter(tablet, getTagFilterMatcher(), databaseName),
+            databaseName,
+            getColumnFilterMatcher());
     return Objects.nonNull(prunedTablet)
         ? Collections.singletonList(prunedTablet)
         : Collections.emptyList();
@@ -601,7 +641,10 @@
             rowCount);
 
     final Tablet prunedTablet =
-        TabletColumnPruner.pruneTableModelTablet(tablet, databaseName, getColumnFilterMatcher());
+        TabletColumnPruner.pruneTableModelTablet(
+            TabletTagFilter.filter(tablet, getTagFilterMatcher(), databaseName),
+            databaseName,
+            getColumnFilterMatcher());
     return Objects.nonNull(prunedTablet)
         ? Collections.singletonList(prunedTablet)
         : Collections.emptyList();
@@ -718,6 +761,12 @@
         : fallbackColumnFilterMatcher;
   }
 
+  private TagFilterMatcher getTagFilterMatcher() {
+    return Objects.nonNull(topicName)
+        ? SubscriptionAgent.broker().getTagFilterMatcher(topicName, true)
+        : fallbackTagFilterMatcher;
+  }
+
   private boolean isValidColumn(
       final String[] measurements,
       final TSDataType[] dataTypes,
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionSetupHandler.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionSetupHandler.java
index 964bbcb..474deb3 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionSetupHandler.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusSubscriptionSetupHandler.java
@@ -716,6 +716,7 @@
 
     if (isTableTopic) {
       SubscriptionAgent.broker().refreshColumnFilter(topicName, topicConfig);
+      SubscriptionAgent.broker().refreshTagFilter(topicName, topicConfig);
       // Table model: database + table name pattern
       tablePattern = buildTablePattern(topicConfig);
       return new ConsensusLogToTabletConverter(
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/columnfilter/ColumnFilterParser.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/columnfilter/ColumnFilterParser.java
index 2e7200a..1770900 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/columnfilter/ColumnFilterParser.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/columnfilter/ColumnFilterParser.java
@@ -61,7 +61,8 @@
   private static final Pattern SINGLE_FIELD_PATTERN =
       Pattern.compile("\\s*(?:[A-Za-z_][A-Za-z_0-9]*|\"(?:\"\"|[^\"])*\")\\s*");
   private static final Pattern FUNCTION_CALL_START_PATTERN =
-      Pattern.compile("\\s*(?:[A-Za-z_][A-Za-z_0-9]*|\"(?:\"\"|[^\"])*\")\\s*\\(.*");
+      // NOT (...) is a logical expression supported by the grammar.
+      Pattern.compile("\\s*(?!(?i:NOT)\\b)(?:[A-Za-z_][A-Za-z_0-9]*|\"(?:\"\"|[^\"])*\")\\s*\\(.*");
   private static final Pattern UNQUOTED_COMPARISON_RIGHT_PATTERN =
       Pattern.compile("(?is).*(?:!=|<>|=)\\s*[A-Za-z_][A-Za-z_0-9]*\\s*");
 
@@ -90,7 +91,7 @@
     }
   }
 
-  Expression parse(final String rawColumnFilter) {
+  public Expression parse(final String rawColumnFilter) {
     if (rawColumnFilter == null || rawColumnFilter.trim().isEmpty()) {
       throw new ParsingException(
           DataNodeMiscMessages.COLUMN_FILTER_SHOULD_NOT_BE_EMPTY, null, 1, 1);
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionFilterSnapshot.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionFilterSnapshot.java
new file mode 100644
index 0000000..02dc4ec
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionFilterSnapshot.java
@@ -0,0 +1,125 @@
+/*
+ * 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.subscription.event.batch;
+
+import org.apache.iotdb.db.i18n.DataNodeMiscMessages;
+import org.apache.iotdb.db.subscription.agent.SubscriptionAgent;
+import org.apache.iotdb.db.subscription.broker.SubscriptionPrefetchingQueue;
+import org.apache.iotdb.db.subscription.columnfilter.ColumnFilterMatcher;
+import org.apache.iotdb.db.subscription.tagfilter.TagFilterEvaluationException;
+import org.apache.iotdb.db.subscription.tagfilter.TagFilterMatcher;
+import org.apache.iotdb.rpc.subscription.config.TopicConfig;
+
+import java.util.Objects;
+
+/** Immutable topic-filter state used by every event in one subscription batch. */
+final class SubscriptionFilterSnapshot {
+
+  private static final int MAX_CAPTURE_ATTEMPTS = 3;
+
+  private final boolean tableModel;
+  private final TopicConfig topicConfig;
+  private final long filterVersion;
+  private final TagFilterMatcher tagFilterMatcher;
+  private final ColumnFilterMatcher columnFilterMatcher;
+
+  private SubscriptionFilterSnapshot(
+      final boolean tableModel,
+      final TopicConfig topicConfig,
+      final TagFilterMatcher tagFilterMatcher,
+      final ColumnFilterMatcher columnFilterMatcher) {
+    this.tableModel = tableModel;
+    this.topicConfig = topicConfig;
+    this.filterVersion = topicConfig.getFilterVersion();
+    this.tagFilterMatcher = tagFilterMatcher;
+    this.columnFilterMatcher = columnFilterMatcher;
+  }
+
+  static SubscriptionFilterSnapshot capture(final SubscriptionPrefetchingQueue queue) {
+    final boolean tableModel =
+        SubscriptionAgent.consumer().isTableModel(queue.getConsumerGroupId());
+    for (int attempt = 0; attempt < MAX_CAPTURE_ATTEMPTS; attempt++) {
+      final TopicConfig topicConfig = getCurrentTopicConfig(queue, tableModel);
+      if (Objects.isNull(topicConfig)) {
+        throw new TagFilterEvaluationException(
+            DataNodeMiscMessages
+                .EXCEPTION_TOPIC_CONFIGURATION_IS_NOT_AVAILABLE_FOR_TAG_FILTER_1E023E3A);
+      }
+
+      final TagFilterMatcher tagFilterMatcher =
+          SubscriptionAgent.broker().getTagFilterMatcher(queue.getTopicName(), tableModel);
+      tagFilterMatcher.throwIfFailure();
+      final ColumnFilterMatcher columnFilterMatcher =
+          SubscriptionAgent.broker().getColumnFilterMatcher(queue.getTopicName(), tableModel);
+      // Column-filter binding failures are represented by a match-none matcher, not an exception
+      // state. Keeping that matcher also fails closed for every event in this batch.
+
+      final TopicConfig currentTopicConfig = getCurrentTopicConfig(queue, tableModel);
+      if (Objects.nonNull(currentTopicConfig)
+          && topicConfig.getFilterVersion() == currentTopicConfig.getFilterVersion()
+          && tagFilterMatcher
+              == SubscriptionAgent.broker().getTagFilterMatcher(queue.getTopicName(), tableModel)
+          && columnFilterMatcher
+              == SubscriptionAgent.broker()
+                  .getColumnFilterMatcher(queue.getTopicName(), tableModel)) {
+        return new SubscriptionFilterSnapshot(
+            tableModel, new TopicConfig(topicConfig), tagFilterMatcher, columnFilterMatcher);
+      }
+    }
+
+    throw new TagFilterEvaluationException(
+        DataNodeMiscMessages
+            .EXCEPTION_TOPIC_CONFIGURATION_CHANGED_WHILE_CAPTURING_TAG_FILTER_SNAPSHOT_984CEB1B);
+  }
+
+  boolean isCurrent(final SubscriptionPrefetchingQueue queue) {
+    if (!tableModel) {
+      return true;
+    }
+    final TopicConfig currentTopicConfig = getCurrentTopicConfig(queue, true);
+    return Objects.nonNull(currentTopicConfig)
+        && filterVersion == currentTopicConfig.getFilterVersion()
+        && tagFilterMatcher
+            == SubscriptionAgent.broker().getTagFilterMatcher(queue.getTopicName(), true)
+        && columnFilterMatcher
+            == SubscriptionAgent.broker().getColumnFilterMatcher(queue.getTopicName(), true);
+  }
+
+  TopicConfig getTopicConfig() {
+    return topicConfig;
+  }
+
+  TagFilterMatcher getTagFilterMatcher() {
+    return tagFilterMatcher;
+  }
+
+  ColumnFilterMatcher getColumnFilterMatcher() {
+    return columnFilterMatcher;
+  }
+
+  boolean hasNonTrivialFilter() {
+    return tableModel && (!tagFilterMatcher.isMatchAll() || !columnFilterMatcher.isMatchAll());
+  }
+
+  private static TopicConfig getCurrentTopicConfig(
+      final SubscriptionPrefetchingQueue queue, final boolean tableModel) {
+    return SubscriptionAgent.topic().getTopicConfig(queue.getTopicName(), tableModel);
+  }
+}
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeEventBatch.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeEventBatch.java
index d9361bf..741baba 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeEventBatch.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeEventBatch.java
@@ -114,7 +114,7 @@
 
   /////////////////////////////// utility ///////////////////////////////
 
-  protected abstract void onTabletInsertionEvent(final TabletInsertionEvent event);
+  protected abstract void onTabletInsertionEvent(final TabletInsertionEvent event) throws Exception;
 
   protected abstract void onTsFileInsertionEvent(final TsFileInsertionEvent event);
 
@@ -122,6 +122,10 @@
 
   protected abstract List<SubscriptionEvent> generateSubscriptionEvents() throws Exception;
 
+  protected boolean isCompatibleWithCurrentTopicConfig() {
+    return true;
+  }
+
   //////////////////////////// APIs provided for metric framework ////////////////////////////
 
   public int getPipeEventCount() {
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeEventBatches.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeEventBatches.java
index 0cb81ca..d16b357 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeEventBatches.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeEventBatches.java
@@ -25,6 +25,7 @@
 import org.apache.iotdb.db.subscription.broker.SubscriptionPrefetchingTabletQueue;
 import org.apache.iotdb.db.subscription.broker.SubscriptionPrefetchingTsFileQueue;
 import org.apache.iotdb.db.subscription.event.SubscriptionEvent;
+import org.apache.iotdb.rpc.subscription.exception.SubscriptionException;
 
 import com.google.common.collect.ImmutableList;
 import org.slf4j.Logger;
@@ -77,6 +78,7 @@
           }
         } catch (final Exception e) {
           LOGGER.warn(DataNodeMiscMessages.EXCEPTION_SEALING_EVENTS, batch, e);
+          throw propagate(e);
         }
         if (hasNew.get()) {
           regionIdToBatch.remove(regionId);
@@ -103,33 +105,35 @@
       SubscriptionPipeEventBatch batch = regionIdToBatch.get(regionId);
       if (Objects.isNull(batch)) {
         try {
-          batch =
-              prefetchingQueue instanceof SubscriptionPrefetchingTabletQueue
-                  ? new SubscriptionPipeTabletEventBatch(
-                      regionId,
-                      (SubscriptionPrefetchingTabletQueue) prefetchingQueue,
-                      maxDelayInMs,
-                      maxBatchSizeInBytes)
-                  : new SubscriptionPipeTsFileEventBatch(
-                      regionId,
-                      (SubscriptionPrefetchingTsFileQueue) prefetchingQueue,
-                      maxDelayInMs,
-                      maxBatchSizeInBytes);
+          batch = createBatch(regionId);
         } catch (final Exception e) {
           LOGGER.warn(DataNodeMiscMessages.EXCEPTION_CONSTRUCT_NEW_BATCH, e);
           throw e; // rethrow exception for retry
         }
       }
 
+      if (!batch.isCompatibleWithCurrentTopicConfig() && batch.getPipeEventCount() > 0) {
+        if (!batch.emit(consumer)) {
+          throw new SubscriptionException(
+              DataNodeMiscMessages.EXCEPTION_FAILED_TO_SEAL_SUBSCRIPTION_EVENT_BATCH_1FB7E92C);
+        }
+        hasNew.set(true);
+        regionIdToBatch.remove(regionId);
+        batch = createBatch(regionId);
+      }
+
+      final boolean emittedCurrentBatch;
       try {
-        if (batch.onEvent(event, consumer)) {
+        emittedCurrentBatch = batch.onEvent(event, consumer);
+        if (emittedCurrentBatch) {
           hasNew.set(true);
         }
       } catch (final Exception e) {
         LOGGER.warn(DataNodeMiscMessages.EXCEPTION_SEALING_EVENTS, batch, e);
+        throw e;
       }
 
-      if (hasNew.get()) {
+      if (emittedCurrentBatch) {
         regionIdToBatch.remove(regionId);
       } else {
         regionIdToBatch.put(regionId, batch);
@@ -175,4 +179,26 @@
     regionIdToBatch.values().forEach(batch -> batch.cleanUp(true));
     regionIdToBatch.clear();
   }
+
+  private SubscriptionPipeEventBatch createBatch(final int regionId) {
+    return prefetchingQueue instanceof SubscriptionPrefetchingTabletQueue
+        ? new SubscriptionPipeTabletEventBatch(
+            regionId,
+            (SubscriptionPrefetchingTabletQueue) prefetchingQueue,
+            maxDelayInMs,
+            maxBatchSizeInBytes)
+        : new SubscriptionPipeTsFileEventBatch(
+            regionId,
+            (SubscriptionPrefetchingTsFileQueue) prefetchingQueue,
+            maxDelayInMs,
+            maxBatchSizeInBytes);
+  }
+
+  private static RuntimeException propagate(final Exception exception) {
+    return exception instanceof RuntimeException
+        ? (RuntimeException) exception
+        : new SubscriptionException(
+            DataNodeMiscMessages.EXCEPTION_FAILED_TO_SEAL_SUBSCRIPTION_EVENT_BATCH_1FB7E92C,
+            exception);
+  }
 }
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeTabletEventBatch.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeTabletEventBatch.java
index 1d3987a..0f2653a 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeTabletEventBatch.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeTabletEventBatch.java
@@ -19,27 +19,24 @@
 
 package org.apache.iotdb.db.subscription.event.batch;
 
-import org.apache.iotdb.commons.pipe.config.constant.PipeSourceConstant;
 import org.apache.iotdb.commons.pipe.event.EnrichedEvent;
-import org.apache.iotdb.commons.schema.table.TreeViewSchema;
-import org.apache.iotdb.commons.schema.table.TsTable;
 import org.apache.iotdb.commons.subscription.config.SubscriptionConfig;
+import org.apache.iotdb.db.i18n.DataNodeMiscMessages;
 import org.apache.iotdb.db.i18n.DataNodePipeMessages;
 import org.apache.iotdb.db.pipe.event.common.tablet.PipeInsertNodeTabletInsertionEvent;
 import org.apache.iotdb.db.pipe.event.common.tablet.PipeRawTabletInsertionEvent;
 import org.apache.iotdb.db.pipe.event.common.tsfile.PipeTsFileInsertionEvent;
 import org.apache.iotdb.db.pipe.resource.memory.PipeMemoryWeightUtil;
-import org.apache.iotdb.db.schemaengine.table.DataNodeTableCache;
 import org.apache.iotdb.db.subscription.agent.SubscriptionAgent;
 import org.apache.iotdb.db.subscription.broker.SubscriptionPrefetchingTabletQueue;
 import org.apache.iotdb.db.subscription.columnfilter.ColumnFilterMatcher;
 import org.apache.iotdb.db.subscription.columnfilter.TabletColumnPruner;
-import org.apache.iotdb.db.subscription.columnfilter.TreeViewTabletProjector;
 import org.apache.iotdb.db.subscription.event.SubscriptionEvent;
+import org.apache.iotdb.db.subscription.tagfilter.TabletTagFilter;
+import org.apache.iotdb.db.subscription.tagfilter.TagFilterEvaluationException;
 import org.apache.iotdb.metrics.core.utils.IoTDBMovingAverage;
 import org.apache.iotdb.pipe.api.event.dml.insertion.TabletInsertionEvent;
 import org.apache.iotdb.pipe.api.event.dml.insertion.TsFileInsertionEvent;
-import org.apache.iotdb.rpc.subscription.config.TopicConfig;
 import org.apache.iotdb.rpc.subscription.config.TopicConstant;
 
 import com.codahale.metrics.Clock;
@@ -50,7 +47,6 @@
 import org.slf4j.LoggerFactory;
 
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
@@ -73,8 +69,8 @@
 
   private final Meter insertNodeTabletInsertionEventSizeEstimator;
   private final Meter rawTabletInsertionEventSizeEstimator;
-  private volatile boolean treeViewTabletProjectorInitialized;
-  private volatile TreeViewTabletProjector treeViewTabletProjector;
+  private volatile SubscriptionFilterSnapshot filterSnapshot;
+  private volatile SubscriptionTreeViewProjector treeViewProjector;
 
   private volatile SubscriptionPipeTabletIterationSnapshot iterationSnapshot;
   private final AtomicInteger referenceCount = new AtomicInteger();
@@ -132,6 +128,8 @@
 
   @Override
   protected void onTabletInsertionEvent(final TabletInsertionEvent event) {
+    ensureFilterSnapshot();
+
     // update processing time
     if (firstEventProcessingTime == Long.MIN_VALUE) {
       firstEventProcessingTime = System.currentTimeMillis();
@@ -148,6 +146,8 @@
 
   @Override
   protected void onTsFileInsertionEvent(final TsFileInsertionEvent event) {
+    ensureFilterSnapshot();
+
     // update processing time
     if (firstEventProcessingTime == Long.MIN_VALUE) {
       firstEventProcessingTime = System.currentTimeMillis();
@@ -163,6 +163,7 @@
 
   @Override
   protected List<SubscriptionEvent> generateSubscriptionEvents() {
+    ensureFilterSnapshot();
     if (!prepareTreeViewTabletProjectorForEmission()) {
       return null;
     }
@@ -226,105 +227,46 @@
       return tablets;
     }
 
-    final TreeViewTabletProjector projector = getTreeViewTabletProjector();
-    if (Objects.isNull(projector)) {
-      return tablets;
+    if (!prepareTreeViewTabletProjectorForEmission() || !treeViewProjector.isAvailable()) {
+      if (!ensureFilterSnapshot().hasNonTrivialFilter()) {
+        return tablets;
+      }
+      throw new TagFilterEvaluationException(
+          DataNodeMiscMessages
+              .EXCEPTION_TREE_VIEW_PROJECTOR_IS_UNAVAILABLE_FOR_FILTERED_SUBSCRIPTION_DATA_B5F396A5);
     }
 
     final List<Tablet> projectedTablets = new ArrayList<>(tablets.right.size());
     for (final Tablet tablet : tablets.right) {
-      final Tablet projectedTablet = projector.project(tablet);
+      final Tablet projectedTablet = treeViewProjector.project(tablet);
       if (Objects.nonNull(projectedTablet)) {
         projectedTablets.add(projectedTablet);
       }
     }
     return projectedTablets.isEmpty()
         ? null
-        : new Pair<>(projector.getDatabaseName(), projectedTablets);
-  }
-
-  private TreeViewTabletProjector getTreeViewTabletProjector() {
-    return prepareTreeViewTabletProjectorForEmission() ? treeViewTabletProjector : null;
+        : new Pair<>(treeViewProjector.getDatabaseName(), projectedTablets);
   }
 
   private boolean prepareTreeViewTabletProjectorForEmission() {
-    if (treeViewTabletProjectorInitialized) {
-      return true;
+    final SubscriptionFilterSnapshot snapshot = ensureFilterSnapshot();
+    if (Objects.isNull(treeViewProjector)) {
+      treeViewProjector = new SubscriptionTreeViewProjector(snapshot.getTopicConfig());
     }
-
-    synchronized (this) {
-      if (treeViewTabletProjectorInitialized) {
-        return true;
-      }
-
-      final TopicConfig topicConfig =
-          SubscriptionAgent.topic()
-              .getTopicConfigs(
-                  Collections.singleton(prefetchingQueue.getTopicName()),
-                  SubscriptionAgent.consumer().isTableModel(prefetchingQueue.getConsumerGroupId()))
-              .get(prefetchingQueue.getTopicName());
-      if (Objects.isNull(topicConfig)) {
-        return false;
-      }
-      if (!topicConfig.isTableTopic()) {
-        treeViewTabletProjectorInitialized = true;
-        return true;
-      }
-
-      final String database =
-          topicConfig.getStringOrDefault(
-              TopicConstant.DATABASE_KEY, TopicConstant.DATABASE_DEFAULT_VALUE);
-      final String tableName =
-          topicConfig.getStringOrDefault(
-              TopicConstant.TABLE_KEY, TopicConstant.TABLE_DEFAULT_VALUE);
-      if (isDefaultTopicPattern(database, TopicConstant.DATABASE_DEFAULT_VALUE)
-          || isDefaultTopicPattern(tableName, TopicConstant.TABLE_DEFAULT_VALUE)
-          || !isLiteralTopicPattern(database)
-          || !isLiteralTopicPattern(tableName)) {
-        treeViewTabletProjectorInitialized = true;
-        return true;
-      }
-
-      if (!isTreeCapturedByTopic(topicConfig) && topicConfig.isColumnFilterTrivial()) {
-        treeViewTabletProjectorInitialized = true;
-        return true;
-      }
-
-      final TsTable table = DataNodeTableCache.getInstance().getTable(database, tableName, false);
-      if (Objects.isNull(table)) {
-        LOGGER.debug(
-            DataNodePipeMessages
-                .PIPE_LOG_SUBSCRIPTIONPIPETABLETEVENTBATCH_POSTPONE_EMITTING_SUBSCRIPTION_TABLET_BATCH_FOR_TOPIC_ARG_BECAUSE_TABLE_SCHEMA_ARG_ARG_IS_NOT_AVAILABLE_LOCALLY_996C618D,
-            prefetchingQueue.getTopicName(),
-            database,
-            tableName);
-        return false;
-      }
-      if (TreeViewSchema.isTreeViewTable(table)) {
-        treeViewTabletProjector = new TreeViewTabletProjector(database, table);
-      }
-
-      treeViewTabletProjectorInitialized = true;
-      return true;
+    final boolean prepared = treeViewProjector.prepare();
+    if (!prepared) {
+      LOGGER.debug(
+          DataNodePipeMessages
+              .PIPE_LOG_SUBSCRIPTIONPIPETABLETEVENTBATCH_POSTPONE_EMITTING_SUBSCRIPTION_TABLET_BATCH_FOR_TOPIC_ARG_BECAUSE_TABLE_SCHEMA_ARG_ARG_IS_NOT_AVAILABLE_LOCALLY_996C618D,
+          prefetchingQueue.getTopicName(),
+          snapshot
+              .getTopicConfig()
+              .getStringOrDefault(TopicConstant.DATABASE_KEY, TopicConstant.DATABASE_DEFAULT_VALUE),
+          snapshot
+              .getTopicConfig()
+              .getStringOrDefault(TopicConstant.TABLE_KEY, TopicConstant.TABLE_DEFAULT_VALUE));
     }
-  }
-
-  private static boolean isDefaultTopicPattern(final String pattern, final String defaultPattern) {
-    return Objects.isNull(pattern) || defaultPattern.equals(pattern.trim());
-  }
-
-  private static boolean isLiteralTopicPattern(final String pattern) {
-    final String regexMetaCharacters = ".*+?[](){}\\|^$";
-    return Objects.nonNull(pattern)
-        && pattern.chars().noneMatch(c -> regexMetaCharacters.indexOf((char) c) >= 0);
-  }
-
-  private static boolean isTreeCapturedByTopic(final TopicConfig topicConfig) {
-    return topicConfig.getBooleanOrDefault(
-        Arrays.asList(
-            PipeSourceConstant.EXTRACTOR_CAPTURE_TREE_KEY,
-            PipeSourceConstant.SOURCE_CAPTURE_TREE_KEY),
-        false);
+    return prepared;
   }
 
   private Pair<String, List<Tablet>> pruneTablets(final Pair<String, List<Tablet>> tablets) {
@@ -332,16 +274,15 @@
       return tablets;
     }
 
-    final ColumnFilterMatcher matcher =
-        SubscriptionAgent.broker()
-            .getColumnFilterMatcher(
-                prefetchingQueue.getTopicName(),
-                SubscriptionAgent.consumer().isTableModel(prefetchingQueue.getConsumerGroupId()));
+    final SubscriptionFilterSnapshot snapshot = ensureFilterSnapshot();
 
     final List<Tablet> prunedTablets = new ArrayList<>(tablets.right.size());
     for (final Tablet tablet : tablets.right) {
       final Tablet prunedTablet =
-          TabletColumnPruner.pruneTableModelTablet(tablet, tablets.left, matcher);
+          TabletColumnPruner.pruneTableModelTablet(
+              TabletTagFilter.filter(tablet, snapshot.getTagFilterMatcher(), tablets.left),
+              tablets.left,
+              snapshot.getColumnFilterMatcher());
       if (Objects.nonNull(prunedTablet)) {
         prunedTablets.add(prunedTablet);
       }
@@ -349,6 +290,18 @@
     return prunedTablets.isEmpty() ? null : new Pair<>(tablets.left, prunedTablets);
   }
 
+  @Override
+  protected boolean isCompatibleWithCurrentTopicConfig() {
+    return Objects.isNull(filterSnapshot) || filterSnapshot.isCurrent(prefetchingQueue);
+  }
+
+  private synchronized SubscriptionFilterSnapshot ensureFilterSnapshot() {
+    if (Objects.isNull(filterSnapshot)) {
+      filterSnapshot = SubscriptionFilterSnapshot.capture(prefetchingQueue);
+    }
+    return filterSnapshot;
+  }
+
   /////////////////////////////// estimator ///////////////////////////////
 
   private long getEstimatedInsertNodeTabletInsertionEventSize() {
@@ -381,6 +334,10 @@
     return result;
   }
 
+  public ColumnFilterMatcher getColumnFilterMatcher() {
+    return ensureFilterSnapshot().getColumnFilterMatcher();
+  }
+
   public synchronized void resetForIteration() {
     currentEnrichedEventsIterator = enrichedEvents.iterator();
     currentTabletInsertionEventsIterator = null;
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeTsFileEventBatch.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeTsFileEventBatch.java
index 7a27f46..5bfc9eb 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeTsFileEventBatch.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeTsFileEventBatch.java
@@ -21,16 +21,19 @@
 
 import org.apache.iotdb.commons.pipe.event.EnrichedEvent;
 import org.apache.iotdb.commons.utils.TestOnly;
+import org.apache.iotdb.db.i18n.DataNodeMiscMessages;
 import org.apache.iotdb.db.i18n.DataNodePipeMessages;
 import org.apache.iotdb.db.pipe.sink.payload.evolvable.batch.PipeTabletEventTsFileBatch;
-import org.apache.iotdb.db.subscription.agent.SubscriptionAgent;
+import org.apache.iotdb.db.pipe.sink.payload.evolvable.batch.PipeTabletEventTsFileBatch.TabletTransformResult;
 import org.apache.iotdb.db.subscription.broker.SubscriptionPrefetchingTsFileQueue;
-import org.apache.iotdb.db.subscription.columnfilter.ColumnFilterMatcher;
 import org.apache.iotdb.db.subscription.columnfilter.TabletColumnPruner;
 import org.apache.iotdb.db.subscription.event.SubscriptionEvent;
 import org.apache.iotdb.db.subscription.event.pipe.SubscriptionPipeTsFileBatchEvents;
+import org.apache.iotdb.db.subscription.tagfilter.TabletTagFilter;
+import org.apache.iotdb.db.subscription.tagfilter.TagFilterEvaluationException;
 import org.apache.iotdb.pipe.api.event.dml.insertion.TabletInsertionEvent;
 import org.apache.iotdb.pipe.api.event.dml.insertion.TsFileInsertionEvent;
+import org.apache.iotdb.rpc.subscription.config.TopicConstant;
 import org.apache.iotdb.rpc.subscription.payload.poll.SubscriptionCommitContext;
 
 import org.apache.tsfile.utils.Pair;
@@ -42,6 +45,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 import java.util.concurrent.atomic.AtomicInteger;
 
 public class SubscriptionPipeTsFileEventBatch extends SubscriptionPipeEventBatch {
@@ -51,6 +55,8 @@
 
   private final PipeTabletEventTsFileBatch batch;
   private final List<Pair<String, File>> sealedFilePairs = new ArrayList<>();
+  private volatile SubscriptionFilterSnapshot filterSnapshot;
+  private volatile SubscriptionTreeViewProjector treeViewProjector;
 
   public SubscriptionPipeTsFileEventBatch(
       final int regionId,
@@ -59,8 +65,7 @@
       final long maxBatchSizeInBytes) {
     super(regionId, prefetchingQueue, maxDelayInMs, maxBatchSizeInBytes);
     this.batch =
-        new PipeTabletEventTsFileBatch(
-            maxDelayInMs, maxBatchSizeInBytes, this::pruneTableModelTablet);
+        new PipeTabletEventTsFileBatch(maxDelayInMs, maxBatchSizeInBytes, this::transformTablet);
   }
 
   @TestOnly
@@ -99,12 +104,11 @@
   /////////////////////////////// utility ///////////////////////////////
 
   @Override
-  protected void onTabletInsertionEvent(final TabletInsertionEvent event) {
-    try {
-      batch.onEvent(event);
-    } catch (final Exception ignored) {
-      // no exceptions will be thrown
-    }
+  protected void onTabletInsertionEvent(final TabletInsertionEvent event) throws Exception {
+    ensureFilterSnapshot();
+    // Keep the queue's reference when transformation fails: the prefetching queue retries this
+    // same event without acquiring another reference. Release it only after successful batching.
+    batch.onEvent(event);
     ((EnrichedEvent) event)
         .decreaseReferenceCount(
             SubscriptionPipeTsFileEventBatch.class.getName(),
@@ -123,8 +127,7 @@
   @Override
   protected List<SubscriptionEvent> generateSubscriptionEvents() throws Exception {
     if (batch.isEmpty()) {
-      enrichedEvents.clear();
-      return Collections.emptyList();
+      return discardEmptyBatch();
     }
 
     final List<SubscriptionEvent> events = new ArrayList<>();
@@ -132,8 +135,7 @@
     if (dbTsFilePairs.isEmpty()) {
       batch.decreaseEventsReferenceCount(this.getClass().getName(), true);
       batch.onSuccess();
-      enrichedEvents.clear();
-      return Collections.emptyList();
+      return discardEmptyBatch();
     }
     sealedFilePairs.addAll(dbTsFilePairs);
     final AtomicInteger ackReferenceCount = new AtomicInteger(dbTsFilePairs.size());
@@ -151,17 +153,95 @@
     return events;
   }
 
+  /** Closes the inner conversion batch when filtering produced no TsFile payload. */
+  private List<SubscriptionEvent> discardEmptyBatch() {
+    try {
+      batch.close();
+    } finally {
+      enrichedEvents.clear();
+    }
+    return Collections.emptyList();
+  }
+
   @Override
   protected boolean shouldEmit() {
     return (!enrichedEvents.isEmpty() && batch.isEmpty()) || batch.shouldEmit();
   }
 
-  private Tablet pruneTableModelTablet(final String databaseName, final Tablet tablet) {
-    final ColumnFilterMatcher matcher =
-        SubscriptionAgent.broker()
-            .getColumnFilterMatcher(
-                prefetchingQueue.getTopicName(),
-                SubscriptionAgent.consumer().isTableModel(prefetchingQueue.getConsumerGroupId()));
-    return TabletColumnPruner.pruneTableModelTablet(tablet, databaseName, matcher);
+  private TabletTransformResult transformTablet(
+      final String databaseName,
+      final Tablet tablet,
+      final boolean isTableModel,
+      final boolean isAligned) {
+    final SubscriptionFilterSnapshot snapshot = ensureFilterSnapshot();
+    if (isTableModel) {
+      return new TabletTransformResult(
+          pruneTableModelTablet(databaseName, tablet, snapshot), databaseName, true, isAligned);
+    }
+    if (!snapshot.getTopicConfig().isTableTopic()) {
+      return new TabletTransformResult(tablet, databaseName, false, isAligned);
+    }
+
+    if (!prepareTreeViewProjector(snapshot)) {
+      throw new TagFilterEvaluationException(
+          DataNodeMiscMessages.EXCEPTION_TABLE_SCHEMA_IS_NOT_AVAILABLE_FOR_TAG_FILTER_993AB728);
+    }
+    if (!treeViewProjector.isAvailable()) {
+      if (snapshot.hasNonTrivialFilter()) {
+        throw new TagFilterEvaluationException(
+            DataNodeMiscMessages
+                .EXCEPTION_TREE_VIEW_PROJECTOR_IS_UNAVAILABLE_FOR_FILTERED_SUBSCRIPTION_DATA_B5F396A5);
+      }
+      return new TabletTransformResult(tablet, databaseName, false, isAligned);
+    }
+
+    final Tablet projectedTablet = treeViewProjector.project(tablet);
+    return Objects.isNull(projectedTablet)
+        ? null
+        : new TabletTransformResult(
+            pruneTableModelTablet(treeViewProjector.getDatabaseName(), projectedTablet, snapshot),
+            treeViewProjector.getDatabaseName(),
+            true,
+            true);
+  }
+
+  private Tablet pruneTableModelTablet(
+      final String databaseName, final Tablet tablet, final SubscriptionFilterSnapshot snapshot) {
+    return TabletColumnPruner.pruneTableModelTablet(
+        TabletTagFilter.filter(tablet, snapshot.getTagFilterMatcher(), databaseName),
+        databaseName,
+        snapshot.getColumnFilterMatcher());
+  }
+
+  private boolean prepareTreeViewProjector(final SubscriptionFilterSnapshot snapshot) {
+    if (Objects.isNull(treeViewProjector)) {
+      treeViewProjector = new SubscriptionTreeViewProjector(snapshot.getTopicConfig());
+    }
+    final boolean prepared = treeViewProjector.prepare();
+    if (!prepared) {
+      LOGGER.debug(
+          DataNodePipeMessages
+              .PIPE_LOG_SUBSCRIPTIONPIPETABLETEVENTBATCH_POSTPONE_EMITTING_SUBSCRIPTION_TABLET_BATCH_FOR_TOPIC_ARG_BECAUSE_TABLE_SCHEMA_ARG_ARG_IS_NOT_AVAILABLE_LOCALLY_996C618D,
+          prefetchingQueue.getTopicName(),
+          snapshot
+              .getTopicConfig()
+              .getStringOrDefault(TopicConstant.DATABASE_KEY, TopicConstant.DATABASE_DEFAULT_VALUE),
+          snapshot
+              .getTopicConfig()
+              .getStringOrDefault(TopicConstant.TABLE_KEY, TopicConstant.TABLE_DEFAULT_VALUE));
+    }
+    return prepared;
+  }
+
+  @Override
+  protected boolean isCompatibleWithCurrentTopicConfig() {
+    return Objects.isNull(filterSnapshot) || filterSnapshot.isCurrent(prefetchingQueue);
+  }
+
+  private synchronized SubscriptionFilterSnapshot ensureFilterSnapshot() {
+    if (Objects.isNull(filterSnapshot)) {
+      filterSnapshot = SubscriptionFilterSnapshot.capture(prefetchingQueue);
+    }
+    return filterSnapshot;
   }
 }
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionTreeViewProjector.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionTreeViewProjector.java
new file mode 100644
index 0000000..140e26a
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionTreeViewProjector.java
@@ -0,0 +1,126 @@
+/*
+ * 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.subscription.event.batch;
+
+import org.apache.iotdb.commons.pipe.config.constant.PipeSourceConstant;
+import org.apache.iotdb.commons.schema.table.TreeViewSchema;
+import org.apache.iotdb.commons.schema.table.TsTable;
+import org.apache.iotdb.db.i18n.DataNodeMiscMessages;
+import org.apache.iotdb.db.schemaengine.table.DataNodeTableCache;
+import org.apache.iotdb.db.subscription.columnfilter.TreeViewTabletProjector;
+import org.apache.iotdb.db.subscription.tagfilter.TagFilterEvaluationException;
+import org.apache.iotdb.rpc.subscription.config.TopicConfig;
+import org.apache.iotdb.rpc.subscription.config.TopicConstant;
+
+import org.apache.tsfile.write.record.Tablet;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * Resolves and applies the table projection for tree-model events captured by a Tree View topic.
+ */
+final class SubscriptionTreeViewProjector {
+
+  private final TopicConfig topicConfig;
+
+  private boolean initialized;
+  private TreeViewTabletProjector projector;
+
+  SubscriptionTreeViewProjector(final TopicConfig topicConfig) {
+    this.topicConfig = topicConfig;
+  }
+
+  synchronized boolean prepare() {
+    if (initialized) {
+      return true;
+    }
+    if (Objects.isNull(topicConfig) || !topicConfig.isTableTopic()) {
+      initialized = true;
+      return true;
+    }
+
+    final String database =
+        topicConfig.getStringOrDefault(
+            TopicConstant.DATABASE_KEY, TopicConstant.DATABASE_DEFAULT_VALUE);
+    final String tableName =
+        topicConfig.getStringOrDefault(TopicConstant.TABLE_KEY, TopicConstant.TABLE_DEFAULT_VALUE);
+    if (isDefaultTopicPattern(database, TopicConstant.DATABASE_DEFAULT_VALUE)
+        || isDefaultTopicPattern(tableName, TopicConstant.TABLE_DEFAULT_VALUE)
+        || !isLiteralTopicPattern(database)
+        || !isLiteralTopicPattern(tableName)) {
+      initialized = true;
+      return true;
+    }
+
+    if (!isTreeCapturedByTopic(topicConfig)
+        && topicConfig.isColumnFilterTrivial()
+        && topicConfig.isTagFilterTrivial()) {
+      initialized = true;
+      return true;
+    }
+
+    final TsTable table = DataNodeTableCache.getInstance().getTable(database, tableName, false);
+    if (Objects.isNull(table)) {
+      return false;
+    }
+    if (TreeViewSchema.isTreeViewTable(table)) {
+      projector = new TreeViewTabletProjector(database, table);
+    }
+    initialized = true;
+    return true;
+  }
+
+  Tablet project(final Tablet tablet) {
+    if (Objects.isNull(projector)) {
+      throw new TagFilterEvaluationException(
+          DataNodeMiscMessages
+              .EXCEPTION_TREE_VIEW_PROJECTOR_IS_UNAVAILABLE_FOR_FILTERED_SUBSCRIPTION_DATA_B5F396A5);
+    }
+    // Only a prepared projector can return null to mean that this tablet does not match the view.
+    return projector.project(tablet);
+  }
+
+  boolean isAvailable() {
+    return Objects.nonNull(projector);
+  }
+
+  String getDatabaseName() {
+    return Objects.nonNull(projector) ? projector.getDatabaseName() : null;
+  }
+
+  private static boolean isDefaultTopicPattern(final String pattern, final String defaultPattern) {
+    return Objects.isNull(pattern) || defaultPattern.equals(pattern.trim());
+  }
+
+  private static boolean isLiteralTopicPattern(final String pattern) {
+    final String regexMetaCharacters = ".*+?[](){}\\|^$";
+    return Objects.nonNull(pattern)
+        && pattern.chars().noneMatch(c -> regexMetaCharacters.indexOf((char) c) >= 0);
+  }
+
+  private static boolean isTreeCapturedByTopic(final TopicConfig topicConfig) {
+    return topicConfig.getBooleanOrDefault(
+        Arrays.asList(
+            PipeSourceConstant.EXTRACTOR_CAPTURE_TREE_KEY,
+            PipeSourceConstant.SOURCE_CAPTURE_TREE_KEY),
+        false);
+  }
+}
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/response/SubscriptionEventTabletResponse.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/response/SubscriptionEventTabletResponse.java
index 5854365..3bcbc88 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/response/SubscriptionEventTabletResponse.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/event/response/SubscriptionEventTabletResponse.java
@@ -27,7 +27,6 @@
 import org.apache.iotdb.db.pipe.resource.memory.PipeMemoryManager;
 import org.apache.iotdb.db.pipe.resource.memory.PipeMemoryWeightUtil;
 import org.apache.iotdb.db.pipe.resource.memory.PipeTabletMemoryBlock;
-import org.apache.iotdb.db.subscription.agent.SubscriptionAgent;
 import org.apache.iotdb.db.subscription.broker.SubscriptionPrefetchingQueue;
 import org.apache.iotdb.db.subscription.columnfilter.ColumnFilterMatcher;
 import org.apache.iotdb.db.subscription.event.SubscriptionEvent;
@@ -96,11 +95,7 @@
 
     this.commitContext = commitContext;
     this.rootCommitContext = rootCommitContext;
-    this.columnFilterMatcher =
-        SubscriptionAgent.broker()
-            .getColumnFilterMatcher(
-                queue.getTopicName(),
-                SubscriptionAgent.consumer().isTableModel(queue.getConsumerGroupId()));
+    this.columnFilterMatcher = batch.getColumnFilterMatcher();
 
     init();
   }
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TabletTagFilter.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TabletTagFilter.java
new file mode 100644
index 0000000..dcc7ca7
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TabletTagFilter.java
@@ -0,0 +1,251 @@
+/*
+ * 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.subscription.tagfilter;
+
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Identifier;
+import org.apache.iotdb.db.i18n.DataNodeMiscMessages;
+
+import org.apache.tsfile.common.conf.TSFileConfig;
+import org.apache.tsfile.enums.ColumnCategory;
+import org.apache.tsfile.utils.Binary;
+import org.apache.tsfile.utils.BitMap;
+import org.apache.tsfile.write.record.Tablet;
+import org.apache.tsfile.write.schema.IMeasurementSchema;
+
+import java.lang.reflect.Array;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+/** Filters rows of a table-model Tablet according to TAG column values. */
+public class TabletTagFilter {
+
+  private static final String DELIMITED_FIELD_PREFIX = "D:";
+  private static final String UNDELIMITED_FIELD_PREFIX = "U:";
+
+  private TabletTagFilter() {
+    // utility class
+  }
+
+  public static Tablet filter(final Tablet tablet, final TagFilterMatcher matcher) {
+    return filter(tablet, matcher, null);
+  }
+
+  public static Tablet filter(
+      final Tablet tablet, final TagFilterMatcher matcher, final String databaseName) {
+    if (Objects.isNull(tablet)) {
+      return null;
+    }
+
+    final TagFilterMatcher effectiveMatcher =
+        Objects.nonNull(matcher) ? matcher : TagFilterMatcher.matchAll();
+    effectiveMatcher.throwIfFailure();
+    if (effectiveMatcher.isMatchAll()) {
+      return tablet;
+    }
+    if (effectiveMatcher.isMatchNone() || tablet.getRowSize() <= 0) {
+      return null;
+    }
+
+    final List<IMeasurementSchema> schemas = tablet.getSchemas();
+    // Upstream event conversion supplies a structurally valid Tablet. Only check the metadata
+    // needed to bind the predicate here; do not rescan every value array and null bitmap.
+    final List<ColumnCategory> categories = getColumnCategories(tablet, schemas.size());
+    validateBinding(effectiveMatcher, databaseName, tablet.getTableName());
+    final Map<String, Integer> resolvedFields =
+        resolveReferencedTagColumns(effectiveMatcher.getReferencedFields(), schemas, categories);
+
+    final int[] selectedRows = new int[tablet.getRowSize()];
+    int selectedRowCount = 0;
+    try {
+      for (int rowIndex = 0; rowIndex < tablet.getRowSize(); rowIndex++) {
+        final int currentRowIndex = rowIndex;
+        if (effectiveMatcher.matches(
+            identifier ->
+                getTagValue(tablet, currentRowIndex, resolvedFields.get(toFieldKey(identifier))))) {
+          selectedRows[selectedRowCount++] = rowIndex;
+        }
+      }
+    } catch (final RuntimeException e) {
+      if (e instanceof TagFilterEvaluationException) {
+        throw e;
+      }
+      throw new TagFilterEvaluationException(
+          DataNodeMiscMessages.EXCEPTION_FAILED_TO_EVALUATE_A_TABLET_ROW_7E4E94CE, e);
+    }
+
+    if (selectedRowCount == 0) {
+      return null;
+    }
+    if (selectedRowCount == tablet.getRowSize()) {
+      return tablet;
+    }
+    if (selectedRows[selectedRowCount - 1] == selectedRowCount - 1) {
+      // A prefix needs no row compaction. Clone the outer arrays because the TsFile sorter may
+      // replace their entries; the column arrays and bitmaps themselves can be shared.
+      return new Tablet(
+          tablet.getTableName(),
+          schemas,
+          categories,
+          tablet.getTimestamps(),
+          tablet.getValues().clone(),
+          tablet.getBitMaps() == null ? null : tablet.getBitMaps().clone(),
+          selectedRowCount);
+    }
+    return copySelectedRows(tablet, schemas, categories, selectedRows, selectedRowCount);
+  }
+
+  private static void validateBinding(
+      final TagFilterMatcher matcher, final String databaseName, final String tableName) {
+    if (!matcher.isBindingEnforced()) {
+      return;
+    }
+    final TagFilterMatcher.TableBinding binding =
+        matcher.getTableBindings().get(TagFilterMatcher.TableKey.of(databaseName, tableName));
+    if (binding == null) {
+      throw TagFilterEvaluationException.schemaBinding(
+          DataNodeMiscMessages.EXCEPTION_TABLE_BINDING_IS_NOT_AVAILABLE_882C5F2F);
+    }
+    if (binding.isFailed()) {
+      throw TagFilterEvaluationException.schemaBinding(binding.getFailureReason());
+    }
+    for (final Identifier identifier : matcher.getReferencedFields()) {
+      final String normalizedName = identifier.getValue().toLowerCase(Locale.ROOT);
+      if (!binding.getTagNames().contains(normalizedName)) {
+        throw TagFilterEvaluationException.schemaBinding(
+            DataNodeMiscMessages.EXCEPTION_REFERENCED_COLUMN_IS_NOT_A_TAG_COLUMN_33FA34BC);
+      }
+    }
+  }
+
+  private static Map<String, Integer> resolveReferencedTagColumns(
+      final List<Identifier> referencedFields,
+      final List<IMeasurementSchema> schemas,
+      final List<ColumnCategory> categories) {
+    final Map<String, Integer> exactColumns = new HashMap<>();
+    final Map<String, Integer> normalizedColumns = new HashMap<>();
+    for (int i = 0; i < schemas.size(); i++) {
+      final IMeasurementSchema schema = schemas.get(i);
+      exactColumns.put(schema.getMeasurementName(), i);
+      normalizedColumns.put(schema.getMeasurementName().toLowerCase(Locale.ROOT), i);
+    }
+
+    final Map<String, Integer> result = new HashMap<>();
+    for (final Identifier identifier : referencedFields) {
+      final Integer columnIndex =
+          identifier.isDelimited()
+              ? exactColumns.get(identifier.getValue())
+              : normalizedColumns.get(identifier.getValue().toLowerCase(Locale.ROOT));
+      if (Objects.isNull(columnIndex)) {
+        throw TagFilterEvaluationException.schemaBinding(
+            DataNodeMiscMessages.EXCEPTION_REFERENCED_TAG_COLUMN_IS_MISSING_55E07377);
+      }
+      if (categories.get(columnIndex) != ColumnCategory.TAG) {
+        throw TagFilterEvaluationException.schemaBinding(
+            DataNodeMiscMessages.EXCEPTION_REFERENCED_COLUMN_IS_NOT_A_TAG_COLUMN_33FA34BC);
+      }
+      result.put(toFieldKey(identifier), columnIndex);
+    }
+    return result;
+  }
+
+  private static String getTagValue(
+      final Tablet tablet, final int rowIndex, final Integer columnIndex) {
+    if (Objects.isNull(columnIndex) || tablet.isNull(rowIndex, columnIndex)) {
+      return null;
+    }
+    final Object value = tablet.getValue(rowIndex, columnIndex);
+    if (value instanceof Binary) {
+      return ((Binary) value).getStringValue(TSFileConfig.STRING_CHARSET);
+    }
+    return Objects.nonNull(value) ? String.valueOf(value) : null;
+  }
+
+  private static Tablet copySelectedRows(
+      final Tablet tablet,
+      final List<IMeasurementSchema> schemas,
+      final List<ColumnCategory> categories,
+      final int[] selectedRows,
+      final int selectedRowCount) {
+    try {
+      final long[] timestamps = new long[selectedRowCount];
+      for (int targetRow = 0; targetRow < selectedRowCount; targetRow++) {
+        timestamps[targetRow] = tablet.getTimestamp(selectedRows[targetRow]);
+      }
+      final Object[] sourceValues = tablet.getValues();
+      final Object[] values = new Object[schemas.size()];
+      final BitMap[] sourceBitMaps = tablet.getBitMaps();
+      final BitMap[] bitMaps = sourceBitMaps == null ? null : new BitMap[schemas.size()];
+      for (int columnIndex = 0; columnIndex < schemas.size(); columnIndex++) {
+        final Object sourceColumn = sourceValues[columnIndex];
+        if (sourceColumn != null) {
+          values[columnIndex] =
+              Array.newInstance(sourceColumn.getClass().getComponentType(), selectedRowCount);
+          for (int targetRow = 0; targetRow < selectedRowCount; targetRow++) {
+            System.arraycopy(
+                sourceColumn, selectedRows[targetRow], values[columnIndex], targetRow, 1);
+          }
+        }
+        if (sourceBitMaps != null && sourceBitMaps[columnIndex] != null) {
+          bitMaps[columnIndex] = new BitMap(selectedRowCount);
+          for (int targetRow = 0; targetRow < selectedRowCount; targetRow++) {
+            if (sourceBitMaps[columnIndex].isMarked(selectedRows[targetRow])) {
+              bitMaps[columnIndex].mark(targetRow);
+            }
+          }
+        }
+      }
+      // Filtering changes rows only; schema descriptors and column categories stay valid.
+      return new Tablet(
+          tablet.getTableName(),
+          schemas,
+          categories,
+          timestamps,
+          values,
+          bitMaps,
+          selectedRowCount);
+    } catch (final RuntimeException e) {
+      if (e instanceof TagFilterEvaluationException) {
+        throw e;
+      }
+      throw new TagFilterEvaluationException(
+          DataNodeMiscMessages.EXCEPTION_FAILED_TO_COMPACT_FILTERED_TABLET_5D4AD8AA, e);
+    }
+  }
+
+  private static List<ColumnCategory> getColumnCategories(
+      final Tablet tablet, final int columnCount) {
+    final List<ColumnCategory> categories = tablet.getColumnTypes();
+    if (Objects.isNull(categories) || categories.size() < columnCount) {
+      throw TagFilterEvaluationException.schemaBinding(
+          DataNodeMiscMessages.EXCEPTION_TABLET_COLUMN_CATEGORIES_ARE_MISSING_2C660532);
+    }
+    return categories;
+  }
+
+  private static String toFieldKey(final Identifier identifier) {
+    return (identifier.isDelimited() ? DELIMITED_FIELD_PREFIX : UNDELIMITED_FIELD_PREFIX)
+        + (identifier.isDelimited()
+            ? identifier.getValue()
+            : identifier.getValue().toLowerCase(Locale.ROOT));
+  }
+}
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterEvaluationException.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterEvaluationException.java
new file mode 100644
index 0000000..4204273
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterEvaluationException.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.db.subscription.tagfilter;
+
+import org.apache.iotdb.db.i18n.DataNodeMiscMessages;
+
+/** Raised when a TAG filter cannot be evaluated safely for an input event. */
+public class TagFilterEvaluationException extends RuntimeException {
+
+  public TagFilterEvaluationException(final String reason) {
+    super(
+        String.format(
+            DataNodeMiscMessages.EXCEPTION_TAG_FILTER_EVALUATION_FAILED_ARG_1B239B2F, reason));
+  }
+
+  public TagFilterEvaluationException(final String reason, final Throwable cause) {
+    super(
+        String.format(
+            DataNodeMiscMessages.EXCEPTION_TAG_FILTER_EVALUATION_FAILED_ARG_1B239B2F, reason),
+        cause);
+  }
+
+  public static TagFilterEvaluationException schemaBinding(final String reason) {
+    return new TagFilterEvaluationException(
+        String.format(
+            DataNodeMiscMessages.EXCEPTION_TAG_FILTER_SCHEMA_BINDING_FAILED_ARG_7A5D2D47, reason));
+  }
+}
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterEvaluator.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterEvaluator.java
new file mode 100644
index 0000000..39dd152
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterEvaluator.java
@@ -0,0 +1,228 @@
+/*
+ * 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.subscription.tagfilter;
+
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.BooleanLiteral;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.CommonQueryAstVisitor;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.ComparisonExpression;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Expression;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.FunctionCall;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Identifier;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.InListExpression;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.InPredicate;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.IsNullPredicate;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.LikePredicate;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.LogicalExpression;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Node;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.NotExpression;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.StringLiteral;
+import org.apache.iotdb.db.i18n.DataNodeMiscMessages;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+/** Evaluates a validated tag-filter with SQL three-valued NULL semantics. */
+public class TagFilterEvaluator
+    implements CommonQueryAstVisitor<
+        TagFilterEvaluator.TruthValue, TagFilterEvaluator.ValueProvider> {
+
+  enum TruthValue {
+    TRUE,
+    FALSE,
+    UNKNOWN
+  }
+
+  private final Map<Expression, Pattern> compiledPatterns;
+
+  private TagFilterEvaluator() {
+    this(Collections.emptyMap());
+  }
+
+  private TagFilterEvaluator(final Map<Expression, Pattern> compiledPatterns) {
+    this.compiledPatterns = compiledPatterns;
+  }
+
+  @FunctionalInterface
+  public interface ValueProvider {
+
+    String getValue(Identifier identifier);
+  }
+
+  public static boolean evaluate(final Expression expression, final ValueProvider valueProvider) {
+    final TagFilterValidator.ValidationResult validation =
+        TagFilterValidator.validateAndCompile(expression);
+    return evaluate(expression, valueProvider, validation.getCompiledPatterns());
+  }
+
+  public static boolean evaluate(
+      final Expression expression,
+      final ValueProvider valueProvider,
+      final Map<Expression, Pattern> compiledPatterns) {
+    return new TagFilterEvaluator(compiledPatterns).process(expression, valueProvider)
+        == TruthValue.TRUE;
+  }
+
+  @Override
+  public TruthValue visitNode(final Node node, final ValueProvider context) {
+    throw new IllegalArgumentException(
+        String.format(
+            DataNodeMiscMessages.UNSUPPORTED_EXPRESSION_FMT, node.getClass().getSimpleName()));
+  }
+
+  @Override
+  public TruthValue visitBooleanLiteral(final BooleanLiteral node, final ValueProvider context) {
+    return node.getValue() ? TruthValue.TRUE : TruthValue.FALSE;
+  }
+
+  @Override
+  public TruthValue visitLogicalExpression(
+      final LogicalExpression node, final ValueProvider context) {
+    // UNKNOWN is not decisive: UNKNOWN AND FALSE is FALSE, and UNKNOWN OR TRUE is TRUE.
+    // This also matters under NOT, where returning UNKNOWN early could discard a matching row.
+    boolean hasUnknown = false;
+    if (node.getOperator() == LogicalExpression.Operator.AND) {
+      for (final Expression term : node.getTerms()) {
+        final TruthValue result = process(term, context);
+        if (result == TruthValue.FALSE) {
+          return TruthValue.FALSE;
+        }
+        hasUnknown |= result == TruthValue.UNKNOWN;
+      }
+      return hasUnknown ? TruthValue.UNKNOWN : TruthValue.TRUE;
+    }
+
+    for (final Expression term : node.getTerms()) {
+      final TruthValue result = process(term, context);
+      if (result == TruthValue.TRUE) {
+        return TruthValue.TRUE;
+      }
+      hasUnknown |= result == TruthValue.UNKNOWN;
+    }
+    return hasUnknown ? TruthValue.UNKNOWN : TruthValue.FALSE;
+  }
+
+  @Override
+  public TruthValue visitNotExpression(final NotExpression node, final ValueProvider context) {
+    final TruthValue result = process(node.getValue(), context);
+    if (result == TruthValue.UNKNOWN) {
+      return TruthValue.UNKNOWN;
+    }
+    return result == TruthValue.TRUE ? TruthValue.FALSE : TruthValue.TRUE;
+  }
+
+  @Override
+  public TruthValue visitComparisonExpression(
+      final ComparisonExpression node, final ValueProvider context) {
+    final String left = context.getValue((Identifier) node.getLeft());
+    if (Objects.isNull(left)) {
+      return TruthValue.UNKNOWN;
+    }
+    final boolean equals = left.equals(((StringLiteral) node.getRight()).getValue());
+    return booleanValue(
+        node.getOperator() == ComparisonExpression.Operator.EQUAL ? equals : !equals);
+  }
+
+  @Override
+  public TruthValue visitInPredicate(final InPredicate node, final ValueProvider context) {
+    final String left = context.getValue((Identifier) node.getValue());
+    if (Objects.isNull(left)) {
+      return TruthValue.UNKNOWN;
+    }
+    for (final Expression expression : ((InListExpression) node.getValueList()).getValues()) {
+      if (left.equals(((StringLiteral) expression).getValue())) {
+        return TruthValue.TRUE;
+      }
+    }
+    return TruthValue.FALSE;
+  }
+
+  @Override
+  public TruthValue visitLikePredicate(final LikePredicate node, final ValueProvider context) {
+    final String left = context.getValue((Identifier) node.getValue());
+    if (Objects.isNull(left)) {
+      return TruthValue.UNKNOWN;
+    }
+    final Pattern pattern = compiledPatterns.get(node);
+    if (pattern == null) {
+      throw new IllegalArgumentException(
+          DataNodeMiscMessages.EXCEPTION_UNCOMPILED_LIKE_PREDICATE_01AEE439);
+    }
+    return booleanValue(pattern.matcher(left).matches());
+  }
+
+  @Override
+  public TruthValue visitFunctionCall(final FunctionCall node, final ValueProvider context) {
+    final String left = context.getValue((Identifier) node.getArguments().get(0));
+    if (Objects.isNull(left)) {
+      return TruthValue.UNKNOWN;
+    }
+    final Pattern pattern = compiledPatterns.get(node);
+    if (pattern == null) {
+      throw new IllegalArgumentException(
+          DataNodeMiscMessages.EXCEPTION_UNCOMPILED_REGEXP_PREDICATE_2B0DD646);
+    }
+    return booleanValue(pattern.matcher(left).matches());
+  }
+
+  @Override
+  public TruthValue visitIsNullPredicate(final IsNullPredicate node, final ValueProvider context) {
+    return booleanValue(Objects.isNull(context.getValue((Identifier) node.getValue())));
+  }
+
+  static Pattern compileLikePattern(final String pattern, final String escape) {
+    final Character escapeChar;
+    if (Objects.isNull(escape)) {
+      escapeChar = null;
+    } else if (escape.length() == 1) {
+      escapeChar = escape.charAt(0);
+    } else {
+      throw new IllegalArgumentException(DataNodeMiscMessages.LIKE_ESCAPE_MUST_BE_SINGLE_CHARACTER);
+    }
+
+    final StringBuilder regex = new StringBuilder();
+    boolean escaping = false;
+    for (int i = 0; i < pattern.length(); i++) {
+      final char ch = pattern.charAt(i);
+      if (Objects.nonNull(escapeChar) && ch == escapeChar && !escaping) {
+        escaping = true;
+        continue;
+      }
+      if (!escaping && ch == '%') {
+        regex.append(".*");
+      } else if (!escaping && ch == '_') {
+        regex.append('.');
+      } else {
+        regex.append(Pattern.quote(String.valueOf(ch)));
+      }
+      escaping = false;
+    }
+    if (escaping) {
+      throw new IllegalArgumentException(
+          DataNodeMiscMessages.LIKE_PATTERN_ENDS_WITH_ESCAPE_CHARACTER);
+    }
+    return Pattern.compile(regex.toString(), Pattern.DOTALL);
+  }
+
+  private static TruthValue booleanValue(final boolean value) {
+    return value ? TruthValue.TRUE : TruthValue.FALSE;
+  }
+}
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterMatcher.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterMatcher.java
new file mode 100644
index 0000000..7034a21
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterMatcher.java
@@ -0,0 +1,423 @@
+/*
+ * 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.subscription.tagfilter;
+
+import org.apache.iotdb.commons.pipe.datastructure.pattern.TablePattern;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Expression;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Identifier;
+import org.apache.iotdb.commons.schema.table.TsTable;
+import org.apache.iotdb.commons.schema.table.column.TsTableColumnCategory;
+import org.apache.iotdb.commons.schema.table.column.TsTableColumnSchema;
+import org.apache.iotdb.db.i18n.DataNodeMiscMessages;
+import org.apache.iotdb.rpc.subscription.config.TopicConfig;
+import org.apache.iotdb.rpc.subscription.config.TopicConstant;
+import org.apache.iotdb.rpc.subscription.exception.SubscriptionException;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/** Immutable parsed tag-filter cached per topic. */
+public class TagFilterMatcher {
+
+  public enum RuntimeStatus {
+    MATCH_ALL,
+    MATCH_NONE,
+    ACTIVE,
+    ERROR
+  }
+
+  private static final TagFilterMatcher MATCH_NONE =
+      new TagFilterMatcher(
+          null, Collections.emptyList(), Collections.emptyMap(), State.MATCH_NONE, null, false);
+
+  private final Expression expression;
+  private final List<Identifier> referencedFields;
+  private final Map<Expression, Pattern> compiledPatterns;
+  private final State state;
+  private final Throwable failure;
+  private final Map<TableKey, TableBinding> tableBindings;
+  private final boolean bindingEnforced;
+
+  private enum State {
+    MATCH_ALL,
+    MATCH_NONE,
+    ACTIVE,
+    FAILURE
+  }
+
+  private TagFilterMatcher(
+      final Expression expression,
+      final List<Identifier> referencedFields,
+      final Map<Expression, Pattern> compiledPatterns,
+      final State state,
+      final Throwable failure,
+      final boolean bindingEnforced) {
+    this(
+        expression,
+        referencedFields,
+        compiledPatterns,
+        state,
+        failure,
+        Collections.emptyMap(),
+        bindingEnforced);
+  }
+
+  private TagFilterMatcher(
+      final Expression expression,
+      final List<Identifier> referencedFields,
+      final Map<Expression, Pattern> compiledPatterns,
+      final State state,
+      final Throwable failure,
+      final Map<TableKey, TableBinding> tableBindings,
+      final boolean bindingEnforced) {
+    this.expression = expression;
+    this.referencedFields = referencedFields;
+    this.compiledPatterns = compiledPatterns;
+    this.state = state;
+    this.failure = failure;
+    this.tableBindings = tableBindings;
+    this.bindingEnforced = bindingEnforced;
+  }
+
+  private static final TagFilterMatcher MATCH_ALL =
+      new TagFilterMatcher(
+          null, Collections.emptyList(), Collections.emptyMap(), State.MATCH_ALL, null, false);
+
+  public static TagFilterMatcher matchAll() {
+    return MATCH_ALL;
+  }
+
+  public static TagFilterMatcher matchNone() {
+    return MATCH_NONE;
+  }
+
+  public static TagFilterMatcher failure(final Throwable failure) {
+    return new TagFilterMatcher(
+        null,
+        Collections.emptyList(),
+        Collections.emptyMap(),
+        State.FAILURE,
+        Objects.nonNull(failure) ? failure : new IllegalStateException(),
+        false);
+  }
+
+  public static TagFilterMatcher fromTopicConfig(final TopicConfig topicConfig)
+      throws SubscriptionException {
+    if (Objects.isNull(topicConfig)
+        || !topicConfig.isTableTopic()
+        || topicConfig.isTagFilterTrivial()) {
+      return matchAll();
+    }
+
+    final Expression expression;
+    final TagFilterValidator.ValidationResult validation;
+    try {
+      expression = new TagFilterParser().parse(topicConfig.getTagFilter());
+      validation = TagFilterValidator.validateAndCompile(expression);
+    } catch (final IllegalArgumentException e) {
+      throw new SubscriptionException(
+          String.format(
+              DataNodeMiscMessages.EXCEPTION_INVALID_TAG_FILTER_ARG_E4B1C1C6, e.getMessage()),
+          e);
+    }
+    return new TagFilterMatcher(
+        expression,
+        validation.getReferencedFields(),
+        validation.getCompiledPatterns(),
+        State.ACTIVE,
+        null,
+        false);
+  }
+
+  /**
+   * Parses a filter and validates every currently visible table selected by the topic pattern. An
+   * empty table map is deliberately allowed for a wildcard topic because the table may be created
+   * after the topic; a null map means that a required concrete schema is unavailable.
+   */
+  public static TagFilterMatcher fromTopicConfig(
+      final TopicConfig topicConfig, final Map<String, Map<String, TsTable>> tables)
+      throws SubscriptionException {
+    final TagFilterMatcher matcher = fromTopicConfig(topicConfig);
+    if (matcher.isMatchAll()) {
+      return matcher;
+    }
+    if (tables == null) {
+      throw new SubscriptionException(
+          DataNodeMiscMessages.EXCEPTION_TABLE_SCHEMA_IS_NOT_AVAILABLE_FOR_TAG_FILTER_993AB728);
+    }
+
+    final TablePattern tablePattern =
+        new TablePattern(
+            true,
+            topicConfig.getStringOrDefault(
+                TopicConstant.DATABASE_KEY, TopicConstant.DATABASE_DEFAULT_VALUE),
+            topicConfig.getStringOrDefault(
+                TopicConstant.TABLE_KEY, TopicConstant.TABLE_DEFAULT_VALUE));
+    final Map<TableKey, TableBinding> bindings = new HashMap<>();
+    for (final Map.Entry<String, Map<String, TsTable>> databaseEntry : tables.entrySet()) {
+      if (!tablePattern.matchesDatabase(databaseEntry.getKey())) {
+        continue;
+      }
+      for (final TsTable table : databaseEntry.getValue().values()) {
+        if (table == null || !tablePattern.matchesTable(table.getTableName())) {
+          continue;
+        }
+        final TableBinding binding = bindTable(matcher.referencedFields, table);
+        if (binding.isFailed()) {
+          throw new SubscriptionException(binding.getFailureReason());
+        }
+        bindings.put(TableKey.of(databaseEntry.getKey(), table.getTableName()), binding);
+      }
+    }
+    final boolean bindingEnforced =
+        isLiteralTopicPattern(
+                topicConfig.getStringOrDefault(
+                    TopicConstant.DATABASE_KEY, TopicConstant.DATABASE_DEFAULT_VALUE))
+            && isLiteralTopicPattern(
+                topicConfig.getStringOrDefault(
+                    TopicConstant.TABLE_KEY, TopicConstant.TABLE_DEFAULT_VALUE));
+    return matcher.withBindings(bindings, bindingEnforced);
+  }
+
+  private static boolean isLiteralTopicPattern(final String pattern) {
+    final String regexMetaCharacters = ".*+?[](){}\\|^$";
+    return Objects.nonNull(pattern)
+        && pattern.chars().noneMatch(c -> regexMetaCharacters.indexOf((char) c) >= 0);
+  }
+
+  private static TableBinding bindTable(
+      final List<Identifier> referencedFields, final TsTable table) {
+    final Set<String> tagNames = new HashSet<>();
+    final Set<String> allNames = new HashSet<>();
+    for (final TsTableColumnSchema schema : table.getColumnList()) {
+      if (schema == null || schema.getColumnName() == null) {
+        continue;
+      }
+      allNames.add(schema.getColumnName().toLowerCase(Locale.ROOT));
+      if (schema.getColumnCategory() == TsTableColumnCategory.TAG) {
+        tagNames.add(schema.getColumnName().toLowerCase(Locale.ROOT));
+      }
+    }
+    for (final Identifier identifier : referencedFields) {
+      final String identifierName = identifier.getValue();
+      TsTableColumnSchema matched = null;
+      for (final TsTableColumnSchema schema : table.getColumnList()) {
+        if (schema == null || schema.getColumnName() == null) {
+          continue;
+        }
+        if (identifier.isDelimited()
+            ? schema.getColumnName().equals(identifierName)
+            : schema.getColumnName().equalsIgnoreCase(identifierName)) {
+          matched = schema;
+          break;
+        }
+      }
+      if (matched == null) {
+        return new TableBinding(
+            true,
+            String.format(
+                DataNodeMiscMessages.EXCEPTION_REFERENCED_TAG_COLUMN_IS_MISSING_ARG_F5300BEA,
+                identifierName),
+            tagNames,
+            allNames);
+      }
+      if (matched.getColumnCategory() != TsTableColumnCategory.TAG) {
+        return new TableBinding(
+            true,
+            String.format(
+                DataNodeMiscMessages.EXCEPTION_REFERENCED_COLUMN_IS_NOT_A_TAG_COLUMN_ARG_34D60881,
+                identifierName),
+            tagNames,
+            allNames);
+      }
+    }
+    return new TableBinding(false, null, tagNames, allNames);
+  }
+
+  TagFilterMatcher withBindings(final Map<TableKey, TableBinding> bindings) {
+    return withBindings(bindings, true);
+  }
+
+  TagFilterMatcher withBindings(
+      final Map<TableKey, TableBinding> bindings, final boolean bindingEnforced) {
+    return new TagFilterMatcher(
+        expression,
+        referencedFields,
+        compiledPatterns,
+        state,
+        failure,
+        Objects.nonNull(bindings)
+            ? Collections.unmodifiableMap(new HashMap<>(bindings))
+            : Collections.emptyMap(),
+        bindingEnforced);
+  }
+
+  public boolean isMatchAll() {
+    return state == State.MATCH_ALL;
+  }
+
+  public boolean isMatchNone() {
+    return state == State.MATCH_NONE;
+  }
+
+  public boolean isFailure() {
+    return state == State.FAILURE;
+  }
+
+  public RuntimeStatus getRuntimeStatus() {
+    switch (state) {
+      case MATCH_ALL:
+        return RuntimeStatus.MATCH_ALL;
+      case MATCH_NONE:
+        return RuntimeStatus.MATCH_NONE;
+      case ACTIVE:
+        return RuntimeStatus.ACTIVE;
+      case FAILURE:
+      default:
+        return RuntimeStatus.ERROR;
+    }
+  }
+
+  public String getFailureMessage() {
+    if (!isFailure() || Objects.isNull(failure)) {
+      return null;
+    }
+    Throwable current = failure;
+    while (Objects.isNull(current.getMessage())
+        && Objects.nonNull(current.getCause())
+        && current != current.getCause()) {
+      current = current.getCause();
+    }
+    return Objects.nonNull(current.getMessage())
+        ? current.getMessage()
+        : current.getClass().getSimpleName();
+  }
+
+  public void throwIfFailure() {
+    if (isFailure()) {
+      throw new TagFilterEvaluationException(getFailureMessage(), failure);
+    }
+  }
+
+  List<Identifier> getReferencedFields() {
+    return referencedFields;
+  }
+
+  Map<TableKey, TableBinding> getTableBindings() {
+    return tableBindings;
+  }
+
+  boolean isBindingEnforced() {
+    return bindingEnforced;
+  }
+
+  boolean matches(final TagFilterEvaluator.ValueProvider valueProvider) {
+    throwIfFailure();
+    return !isMatchNone()
+        && (Objects.isNull(expression)
+            || TagFilterEvaluator.evaluate(expression, valueProvider, compiledPatterns));
+  }
+
+  static final class TableKey {
+
+    private final String database;
+    private final String table;
+
+    private TableKey(final String database, final String table) {
+      this.database = normalize(database);
+      this.table = normalize(table);
+    }
+
+    static TableKey of(final String database, final String table) {
+      return new TableKey(database, table);
+    }
+
+    String getDatabase() {
+      return database;
+    }
+
+    String getTable() {
+      return table;
+    }
+
+    @Override
+    public boolean equals(final Object object) {
+      if (this == object) {
+        return true;
+      }
+      if (!(object instanceof TableKey)) {
+        return false;
+      }
+      final TableKey that = (TableKey) object;
+      return Objects.equals(database, that.database) && Objects.equals(table, that.table);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(database, table);
+    }
+
+    private static String normalize(final String value) {
+      return Objects.nonNull(value) ? value.trim().toLowerCase(Locale.ROOT) : "";
+    }
+  }
+
+  static final class TableBinding {
+
+    private final boolean failed;
+    private final String failureReason;
+    private final Set<String> tagNames;
+    private final Set<String> allNames;
+
+    TableBinding(
+        final boolean failed,
+        final String failureReason,
+        final Set<String> tagNames,
+        final Set<String> allNames) {
+      this.failed = failed;
+      this.failureReason = failureReason;
+      this.tagNames = tagNames;
+      this.allNames = allNames;
+    }
+
+    boolean isFailed() {
+      return failed;
+    }
+
+    String getFailureReason() {
+      return failureReason;
+    }
+
+    Set<String> getTagNames() {
+      return tagNames;
+    }
+
+    Set<String> getAllNames() {
+      return allNames;
+    }
+  }
+}
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterParser.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterParser.java
new file mode 100644
index 0000000..1a08394
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterParser.java
@@ -0,0 +1,94 @@
+/*
+ * 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.subscription.tagfilter;
+
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Expression;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.parser.ParsingException;
+import org.apache.iotdb.db.i18n.DataNodeMiscMessages;
+import org.apache.iotdb.db.subscription.columnfilter.ColumnFilterParser;
+import org.apache.iotdb.rpc.subscription.exception.SubscriptionException;
+
+import java.nio.charset.StandardCharsets;
+
+/** Parses the restricted subscription filter expression syntax for table-model TAG values. */
+public class TagFilterParser {
+
+  private static final int MAX_UTF8_BYTES = 4096;
+
+  private final ColumnFilterParser expressionParser = new ColumnFilterParser();
+
+  public Expression parse(final String rawTagFilter) throws SubscriptionException {
+    try {
+      if (rawTagFilter == null || rawTagFilter.trim().isEmpty()) {
+        throw new IllegalArgumentException(
+            DataNodeMiscMessages.EXCEPTION_TAG_FILTER_SHOULD_NOT_BE_EMPTY_507CA5B0);
+      }
+      validateText(rawTagFilter);
+      return expressionParser.parse(rawTagFilter);
+    } catch (final ParsingException | IllegalArgumentException e) {
+      throw new SubscriptionException(
+          String.format(
+              DataNodeMiscMessages.EXCEPTION_INVALID_TAG_FILTER_ARG_E4B1C1C6, e.getMessage()),
+          e);
+    }
+  }
+
+  public Expression parseAndValidate(final String rawTagFilter) throws SubscriptionException {
+    final Expression expression = parse(rawTagFilter);
+    try {
+      TagFilterValidator.validate(expression);
+    } catch (final IllegalArgumentException e) {
+      throw new SubscriptionException(
+          String.format(
+              DataNodeMiscMessages.EXCEPTION_INVALID_TAG_FILTER_ARG_E4B1C1C6, e.getMessage()),
+          e);
+    }
+    return expression;
+  }
+
+  private static void validateText(final String text) {
+    if (text.getBytes(StandardCharsets.UTF_8).length > MAX_UTF8_BYTES) {
+      throw new IllegalArgumentException(
+          String.format(
+              DataNodeMiscMessages
+                  .EXCEPTION_TAG_FILTER_EXCEEDS_MAXIMUM_UTF_8_LENGTH_OF_ARG_BYTES_9C8400F2,
+              MAX_UTF8_BYTES));
+    }
+    for (int i = 0; i < text.length(); i++) {
+      final char current = text.charAt(i);
+      if (Character.isISOControl(current) && !Character.isWhitespace(current)) {
+        throw new IllegalArgumentException(
+            String.format(
+                DataNodeMiscMessages.UNEXPECTED_CHARACTER_FMT, Integer.toHexString(current)));
+      }
+      if (Character.isHighSurrogate(current)) {
+        if (i + 1 >= text.length() || !Character.isLowSurrogate(text.charAt(++i))) {
+          throw new IllegalArgumentException(
+              String.format(
+                  DataNodeMiscMessages.UNEXPECTED_CHARACTER_FMT, Integer.toHexString(current)));
+        }
+      } else if (Character.isLowSurrogate(current)) {
+        throw new IllegalArgumentException(
+            String.format(
+                DataNodeMiscMessages.UNEXPECTED_CHARACTER_FMT, Integer.toHexString(current)));
+      }
+    }
+  }
+}
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterValidator.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterValidator.java
new file mode 100644
index 0000000..2b53205
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterValidator.java
@@ -0,0 +1,252 @@
+/*
+ * 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.subscription.tagfilter;
+
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.BooleanLiteral;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.CommonQueryAstVisitor;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.ComparisonExpression;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Expression;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.FunctionCall;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Identifier;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.InListExpression;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.InPredicate;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.IsNullPredicate;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.LikePredicate;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.LogicalExpression;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Node;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.NotExpression;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.StringLiteral;
+import org.apache.iotdb.db.i18n.DataNodeMiscMessages;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+/** Validates the expression shape and collects the TAG columns referenced by a tag-filter. */
+public class TagFilterValidator implements CommonQueryAstVisitor<Void, Void> {
+
+  static final int MAX_AST_DEPTH = 32;
+  static final int MAX_AST_NODES = 256;
+  static final int MAX_IN_VALUES = 256;
+  static final int MAX_REGEXP_LENGTH = 1024;
+
+  private static final String REGEXP_LIKE = "regexp_like";
+
+  private final List<Identifier> referencedFields = new ArrayList<>();
+  private final Map<Expression, Pattern> compiledPatterns = new IdentityHashMap<>();
+  private int depth;
+  private int nodeCount;
+
+  public static List<Identifier> validate(final Expression expression) {
+    return validateAndCompile(expression).getReferencedFields();
+  }
+
+  public static ValidationResult validateAndCompile(final Expression expression) {
+    final TagFilterValidator validator = new TagFilterValidator();
+    validator.process(expression);
+    return new ValidationResult(
+        Collections.unmodifiableList(new ArrayList<>(validator.referencedFields)),
+        Collections.unmodifiableMap(new IdentityHashMap<>(validator.compiledPatterns)));
+  }
+
+  @Override
+  public Void process(final Node node, final Void context) {
+    if (node == null) {
+      throw invalid(DataNodeMiscMessages.UNSUPPORTED_EXPRESSION_FMT.replace("%s", "null"));
+    }
+    accountNode(node);
+    if (++depth > MAX_AST_DEPTH) {
+      throw invalid(
+          String.format(
+              DataNodeMiscMessages.EXCEPTION_TAG_FILTER_AST_DEPTH_EXCEEDS_MAXIMUM_OF_ARG_64C92731,
+              MAX_AST_DEPTH));
+    }
+    try {
+      return CommonQueryAstVisitor.super.process(node, context);
+    } finally {
+      depth--;
+    }
+  }
+
+  @Override
+  public Void visitNode(final Node node, final Void context) {
+    throw invalid(
+        String.format(
+            DataNodeMiscMessages.UNSUPPORTED_EXPRESSION_FMT, node.getClass().getSimpleName()));
+  }
+
+  @Override
+  public Void visitBooleanLiteral(final BooleanLiteral node, final Void context) {
+    return null;
+  }
+
+  @Override
+  public Void visitLogicalExpression(final LogicalExpression node, final Void context) {
+    node.getTerms().forEach(this::process);
+    return null;
+  }
+
+  @Override
+  public Void visitNotExpression(final NotExpression node, final Void context) {
+    process(node.getValue());
+    return null;
+  }
+
+  @Override
+  public Void visitComparisonExpression(final ComparisonExpression node, final Void context) {
+    if (node.getOperator() != ComparisonExpression.Operator.EQUAL
+        && node.getOperator() != ComparisonExpression.Operator.NOT_EQUAL) {
+      throw invalid(
+          DataNodeMiscMessages.EXCEPTION_ONLY_AND_COMPARISONS_ARE_SUPPORTED_IN_TAG_FILTER_19946958);
+    }
+    requireTagField(node.getLeft());
+    requireStringLiteral(
+        node.getRight(), DataNodeMiscMessages.COLUMN_FILTER_COMPARISON_RIGHT_OPERAND);
+    return null;
+  }
+
+  @Override
+  public Void visitInPredicate(final InPredicate node, final Void context) {
+    requireTagField(node.getValue());
+    if (!(node.getValueList() instanceof InListExpression)) {
+      throw invalid(DataNodeMiscMessages.IN_PREDICATE_MUST_USE_STRING_LITERAL_LIST);
+    }
+    final List<Expression> values = ((InListExpression) node.getValueList()).getValues();
+    if (values.isEmpty() || values.size() > MAX_IN_VALUES) {
+      throw invalid(
+          String.format(
+              DataNodeMiscMessages
+                  .EXCEPTION_TAG_FILTER_IN_LIST_EXCEEDS_MAXIMUM_OF_ARG_VALUES_07E7FC6C,
+              MAX_IN_VALUES));
+    }
+    accountNode(node.getValueList());
+    for (final Expression expression : values) {
+      accountNode(expression);
+      requireStringLiteral(expression, DataNodeMiscMessages.COLUMN_FILTER_IN_ELEMENT);
+    }
+    return null;
+  }
+
+  @Override
+  public Void visitLikePredicate(final LikePredicate node, final Void context) {
+    requireTagField(node.getValue());
+    final StringLiteral pattern =
+        requireStringLiteral(node.getPattern(), DataNodeMiscMessages.COLUMN_FILTER_LIKE_PATTERN);
+    final String escape =
+        node.getEscape()
+            .map(
+                expression ->
+                    requireStringLiteral(expression, DataNodeMiscMessages.COLUMN_FILTER_LIKE_ESCAPE)
+                        .getValue())
+            .orElse(null);
+    compiledPatterns.put(node, TagFilterEvaluator.compileLikePattern(pattern.getValue(), escape));
+    return null;
+  }
+
+  @Override
+  public Void visitFunctionCall(final FunctionCall node, final Void context) {
+    if (!REGEXP_LIKE.equalsIgnoreCase(node.getName().toString())
+        || node.isDistinct()
+        || node.getProcessingMode().isPresent()
+        || node.getArguments().size() != 2) {
+      throw invalid(DataNodeMiscMessages.ONLY_REGEXP_SUPPORTED_AS_REGEXP_LIKE);
+    }
+
+    requireTagField(node.getArguments().get(0));
+    final String pattern =
+        requireStringLiteral(
+                node.getArguments().get(1), DataNodeMiscMessages.COLUMN_FILTER_REGEXP_PATTERN)
+            .getValue();
+    if (pattern.length() > MAX_REGEXP_LENGTH) {
+      throw invalid(
+          String.format(
+              DataNodeMiscMessages
+                  .EXCEPTION_TAG_FILTER_REGEXP_PATTERN_EXCEEDS_MAXIMUM_LENGTH_OF_ARG_CHARACTERS_67590971,
+              MAX_REGEXP_LENGTH));
+    }
+    try {
+      compiledPatterns.put(node, Pattern.compile(pattern));
+    } catch (final PatternSyntaxException e) {
+      throw invalid(String.format(DataNodeMiscMessages.ILLEGAL_REGEXP_PATTERN_FMT, e.getMessage()));
+    }
+    return null;
+  }
+
+  @Override
+  public Void visitIsNullPredicate(final IsNullPredicate node, final Void context) {
+    requireTagField(node.getValue());
+    return null;
+  }
+
+  private Identifier requireTagField(final Expression expression) {
+    if (!(expression instanceof Identifier)) {
+      throw invalid(DataNodeMiscMessages.EXCEPTION_LEFT_OPERAND_MUST_BE_A_TAG_COLUMN_F9D4548B);
+    }
+    final Identifier identifier = (Identifier) expression;
+    referencedFields.add(identifier);
+    return identifier;
+  }
+
+  private static StringLiteral requireStringLiteral(
+      final Expression expression, final String description) {
+    if (!(expression instanceof StringLiteral)) {
+      throw invalid(String.format(DataNodeMiscMessages.MUST_BE_STRING_LITERAL_FMT, description));
+    }
+    return (StringLiteral) expression;
+  }
+
+  private static IllegalArgumentException invalid(final String message) {
+    return new IllegalArgumentException(message);
+  }
+
+  private void accountNode(final Node node) {
+    if (++nodeCount > MAX_AST_NODES) {
+      throw invalid(
+          String.format(
+              DataNodeMiscMessages
+                  .EXCEPTION_TAG_FILTER_AST_NODE_COUNT_EXCEEDS_MAXIMUM_OF_ARG_40CCE694,
+              MAX_AST_NODES));
+    }
+  }
+
+  public static final class ValidationResult {
+
+    private final List<Identifier> referencedFields;
+    private final Map<Expression, Pattern> compiledPatterns;
+
+    private ValidationResult(
+        final List<Identifier> referencedFields, final Map<Expression, Pattern> compiledPatterns) {
+      this.referencedFields = referencedFields;
+      this.compiledPatterns = compiledPatterns;
+    }
+
+    public List<Identifier> getReferencedFields() {
+      return referencedFields;
+    }
+
+    public Map<Expression, Pattern> getCompiledPatterns() {
+      return compiledPatterns;
+    }
+  }
+}
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/pipe/sink/payload/evolvable/batch/PipeTabletEventTsFileBatchTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/pipe/sink/payload/evolvable/batch/PipeTabletEventTsFileBatchTest.java
index 3f70f55..96b665e 100644
--- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/pipe/sink/payload/evolvable/batch/PipeTabletEventTsFileBatchTest.java
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/pipe/sink/payload/evolvable/batch/PipeTabletEventTsFileBatchTest.java
@@ -32,6 +32,44 @@
 public class PipeTabletEventTsFileBatchTest {
 
   @Test
+  public void testTransformerReceivesModelDatabaseAndAlignment() throws Exception {
+    for (final boolean tableModel : new boolean[] {false, true}) {
+      for (final boolean aligned : new boolean[] {false, true}) {
+        final PipeTabletEventTsFileBatch batch =
+            new PipeTabletEventTsFileBatch(
+                100_000,
+                1024 * 1024,
+                (databaseName, tablet, isTableModel, isAligned) -> {
+                  Assert.assertEquals(tableModel, isTableModel);
+                  Assert.assertEquals(tableModel ? "db" : null, databaseName);
+                  Assert.assertEquals(tableModel || aligned, isAligned);
+                  return null;
+                });
+        try {
+          final PipeRawTabletInsertionEvent event =
+              new PipeRawTabletInsertionEvent(
+                  tableModel,
+                  "root.db",
+                  "db",
+                  "root.db",
+                  createTablet(),
+                  aligned,
+                  "pipe",
+                  1L,
+                  null,
+                  null,
+                  false);
+          Assert.assertEquals(tableModel ? "db" : null, event.getTableModelDatabaseName());
+          batch.onEvent(event);
+          Assert.assertTrue(event.isReleased());
+        } finally {
+          batch.close();
+        }
+      }
+    }
+  }
+
+  @Test
   public void testFullyPrunedTableModelTabletIsReleasedAndNotRetained() throws Exception {
     final PipeTabletEventTsFileBatch batch =
         new PipeTabletEventTsFileBatch(100_000, 1024 * 1024, (databaseName, tablet) -> null);
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/execution/config/sys/subscription/ShowTopicsTaskTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/execution/config/sys/subscription/ShowTopicsTaskTest.java
new file mode 100644
index 0000000..0d7ff2e
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/execution/config/sys/subscription/ShowTopicsTaskTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.commons.pipe.config.constant.SystemConstant;
+import org.apache.iotdb.commons.schema.column.ColumnHeaderConstant;
+import org.apache.iotdb.confignode.rpc.thrift.TShowTopicInfo;
+import org.apache.iotdb.db.queryengine.plan.execution.config.ConfigTaskResult;
+import org.apache.iotdb.db.subscription.tagfilter.TagFilterMatcher;
+import org.apache.iotdb.rpc.TSStatusCode;
+import org.apache.iotdb.rpc.subscription.config.TopicConfig;
+import org.apache.iotdb.rpc.subscription.config.TopicConstant;
+
+import com.google.common.util.concurrent.SettableFuture;
+import org.apache.tsfile.read.common.block.TsBlock;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+public class ShowTopicsTaskTest {
+
+  @Test
+  public void testBuildTSBlockWritesTagFilterDiagnostics() throws Exception {
+    final Map<String, TagFilterMatcher> matchers = new HashMap<>();
+    matchers.put("all", TagFilterMatcher.matchAll());
+    matchers.put("none", TagFilterMatcher.matchNone());
+    final Map<String, String> attributes = new HashMap<>();
+    attributes.put(SystemConstant.SQL_DIALECT_KEY, SystemConstant.SQL_DIALECT_TABLE_VALUE);
+    attributes.put(TopicConstant.TAG_FILTER_KEY, "region = \"east\"");
+    matchers.put("active", TagFilterMatcher.fromTopicConfig(new TopicConfig(attributes)));
+    matchers.put("error", TagFilterMatcher.failure(new IllegalStateException("binding failed")));
+    final SettableFuture<ConfigTaskResult> future = SettableFuture.create();
+
+    ShowTopicsTask.buildTSBlock(
+        Arrays.asList(topic("all"), topic("none"), topic("active"), topic("error")),
+        true,
+        matchers::get,
+        future);
+
+    final ConfigTaskResult result = future.get();
+    final TsBlock resultSet = result.getResultSet();
+    assertEquals(TSStatusCode.SUCCESS_STATUS, result.getStatusCode());
+    assertEquals(
+        ColumnHeaderConstant.TAG_FILTER_STATUS,
+        result.getResultSetHeader().getRespColumns().get(2));
+    assertEquals(
+        ColumnHeaderConstant.TAG_FILTER_MESSAGE,
+        result.getResultSetHeader().getRespColumns().get(3));
+    assertEquals("MATCH_ALL", resultSet.getColumn(2).getBinary(0).toString());
+    assertEquals("MATCH_NONE", resultSet.getColumn(2).getBinary(1).toString());
+    assertEquals("ACTIVE", resultSet.getColumn(2).getBinary(2).toString());
+    assertEquals("ERROR", resultSet.getColumn(2).getBinary(3).toString());
+    assertTrue(resultSet.getColumn(3).isNull(0));
+    assertTrue(resultSet.getColumn(3).isNull(1));
+    assertTrue(resultSet.getColumn(3).isNull(2));
+    assertEquals("binding failed", resultSet.getColumn(3).getBinary(3).toString());
+  }
+
+  @Test
+  public void testBuildTreeModelTSBlockLeavesTagFilterDiagnosticsNull() throws Exception {
+    final SettableFuture<ConfigTaskResult> future = SettableFuture.create();
+
+    ShowTopicsTask.buildTSBlock(
+        Arrays.asList(topic("tree")),
+        false,
+        ignored -> TagFilterMatcher.failure(new IllegalStateException()),
+        future);
+
+    final TsBlock resultSet = future.get().getResultSet();
+    assertEquals(2, resultSet.getValueColumnCount());
+  }
+
+  private static TShowTopicInfo topic(final String name) {
+    return new TShowTopicInfo(name, 1L).setTopicAttributes("{}");
+  }
+}
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
index d457bdb..5c8686c 100644
--- 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
@@ -20,18 +20,76 @@
 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.commons.subscription.meta.topic.TopicMeta;
+import org.apache.iotdb.db.queryengine.plan.execution.config.sys.subscription.ShowCreateTopicTask;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AlterTopic;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.CreateTopic;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.util.DataNodeSqlFormatter;
+import org.apache.iotdb.db.subscription.tagfilter.TagFilterEvaluator;
+import org.apache.iotdb.db.subscription.tagfilter.TagFilterParser;
 
 import org.junit.Assert;
 import org.junit.Test;
 
 import java.time.ZoneId;
+import java.util.Map;
 
 public class TopicStatementTest {
 
   private final SqlParser sqlParser = new SqlParser();
 
   @Test
+  public void testTagFilterQuotesInCreateAndAlterTopic() throws Exception {
+    final String attributes =
+        "('tag-filter'='region IN (\"O''Reilly\", \"a''''b\", \"a\"\"\"\"b\", \"O''Reilly \"\"A\"\"\"\"B\"\"\")')";
+    final String expectedFilter =
+        "region IN (\"O'Reilly\", \"a''b\", \"a\"\"\"\"b\", \"O'Reilly \"\"A\"\"\"\"B\"\"\")";
+    for (final String command :
+        new String[] {
+          "CREATE TOPIC topic1",
+          "ALTER TOPIC topic1",
+          "CREATE TOPIC IF NOT EXISTS \"topic-1\"",
+          "ALTER TOPIC \"topic-1\""
+        }) {
+      final Statement statement =
+          sqlParser.createStatement(command + " WITH " + attributes, ZoneId.systemDefault(), null);
+      final Statement formattedStatement =
+          sqlParser.createStatement(
+              DataNodeSqlFormatter.formatDataNodeSql(statement), ZoneId.systemDefault(), null);
+      Assert.assertEquals(statement, formattedStatement);
+      final String filter =
+          statement instanceof CreateTopic
+              ? ((CreateTopic) statement).getTopicAttributes().get("tag-filter")
+              : ((AlterTopic) statement).getTopicAttributes().get("tag-filter");
+      Assert.assertEquals(expectedFilter, filter);
+
+      final TagFilterParser parser = new TagFilterParser();
+      for (final String value :
+          new String[] {"O'Reilly", "a''b", "a\"\"b", "O'Reilly \"A\"\"B\""}) {
+        Assert.assertTrue(
+            command + ": " + value,
+            TagFilterEvaluator.evaluate(parser.parseAndValidate(filter), identifier -> value));
+      }
+      Assert.assertFalse(
+          TagFilterEvaluator.evaluate(parser.parseAndValidate(filter), identifier -> "a\"b"));
+      Assert.assertFalse(
+          TagFilterEvaluator.evaluate(parser.parseAndValidate(filter), identifier -> "O''Reilly"));
+    }
+  }
+
+  @Test
+  public void testShowCreateTopicPreservesTagFilterQuotes() {
+    final String filter = "region = \"O'Reilly \"\"A\"\"\"\"B\"\"\"";
+    final TopicMeta topicMeta = new TopicMeta("topic-1", 0L, Map.of("tag-filter", filter));
+    final CreateTopic recreated =
+        (CreateTopic)
+            sqlParser.createStatement(
+                ShowCreateTopicTask.getShowCreateTopicSQL(topicMeta), ZoneId.systemDefault(), null);
+    Assert.assertEquals("topic-1", recreated.getTopicName());
+    Assert.assertEquals(filter, recreated.getTopicAttributes().get("tag-filter"));
+  }
+
+  @Test
   public void testAlterTopicStatement() {
     final Statement statement =
         sqlParser.createStatement(
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/agent/SubscriptionTopicAgentTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/agent/SubscriptionTopicAgentTest.java
index 1b09a53..1472351 100644
--- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/agent/SubscriptionTopicAgentTest.java
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/agent/SubscriptionTopicAgentTest.java
@@ -182,11 +182,74 @@
         SubscriptionTopicAgent.shouldRefreshColumnFilter(null, createTreeTopicMeta()));
   }
 
+  @Test
+  public void testTagFilterRefreshSkippedForOwnerOnlyUpdate() {
+    final TopicMeta oldMeta =
+        createTableTopicMeta("column_name = \"temperature\"", "region = \"north\"", "db", "t1");
+    final Map<String, String> updatedAttributes = new HashMap<>();
+    updatedAttributes.put(TopicConstant.OWNER_ID_KEY, "owner-1");
+    updatedAttributes.put(TopicConstant.OWNER_EPOCH_KEY, "1");
+
+    Assert.assertFalse(
+        SubscriptionTopicAgent.shouldRefreshTagFilter(
+            oldMeta, oldMeta.deepCopyWithUpdatedAttributes(updatedAttributes)));
+  }
+
+  @Test
+  public void testTagFilterRefreshTriggeredForBindingInputUpdate() {
+    final TopicMeta oldMeta =
+        createTableTopicMeta("column_name = \"temperature\"", "region = \"north\"", "db", "t1");
+
+    Assert.assertTrue(
+        SubscriptionTopicAgent.shouldRefreshTagFilter(
+            oldMeta,
+            createTableTopicMeta(
+                "column_name = \"temperature\"", "region = \"north\"", "db2", "t1")));
+    Assert.assertTrue(
+        SubscriptionTopicAgent.shouldRefreshTagFilter(
+            oldMeta,
+            createTableTopicMeta(
+                "column_name = \"temperature\"", "region = \"north\"", "db", "t2")));
+  }
+
+  @Test
+  public void testTagFilterRefreshTriggeredForCaseSensitiveExpressionUpdate() {
+    final TopicMeta oldMeta =
+        createTableTopicMeta("column_name = \"temperature\"", "region = \"north\"", "db", "t1");
+
+    Assert.assertTrue(
+        SubscriptionTopicAgent.shouldRefreshTagFilter(
+            oldMeta,
+            createTableTopicMeta(
+                "column_name = \"temperature\"", "region = \"North\"", "db", "t1")));
+  }
+
+  @Test
+  public void testTagFilterRefreshTriggeredForNewTableTopicOnly() {
+    Assert.assertTrue(
+        SubscriptionTopicAgent.shouldRefreshTagFilter(
+            null,
+            createTableTopicMeta(
+                "column_name = \"temperature\"", "region = \"north\"", "db", "t1")));
+    Assert.assertFalse(SubscriptionTopicAgent.shouldRefreshTagFilter(null, createTreeTopicMeta()));
+  }
+
   private static TopicMeta createTableTopicMeta(
       final String columnFilter, final String database, final String table) {
+    return createTableTopicMeta(columnFilter, null, database, table);
+  }
+
+  private static TopicMeta createTableTopicMeta(
+      final String columnFilter,
+      final String tagFilter,
+      final String database,
+      final String table) {
     final Map<String, String> attributes = new HashMap<>();
     attributes.put("__system.sql-dialect", "table");
     attributes.put(TopicConstant.COLUMN_FILTER_KEY, columnFilter);
+    if (tagFilter != null) {
+      attributes.put(TopicConstant.TAG_FILTER_KEY, tagFilter);
+    }
     attributes.put(TopicConstant.DATABASE_KEY, database);
     attributes.put(TopicConstant.TABLE_KEY, table);
     return new TopicMeta("topic", 1L, attributes);
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusLogToTabletConverterTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusLogToTabletConverterTest.java
index 1cb1b05..0d92e9d 100644
--- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusLogToTabletConverterTest.java
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/broker/consensus/ConsensusLogToTabletConverterTest.java
@@ -39,6 +39,9 @@
 import org.apache.iotdb.db.storageengine.dataregion.wal.buffer.WALInfoEntry;
 import org.apache.iotdb.db.storageengine.dataregion.wal.utils.WALByteBufferForTest;
 import org.apache.iotdb.db.subscription.columnfilter.ColumnFilterMatcher;
+import org.apache.iotdb.db.subscription.tagfilter.TagFilterMatcher;
+import org.apache.iotdb.rpc.subscription.config.TopicConfig;
+import org.apache.iotdb.rpc.subscription.config.TopicConstant;
 
 import org.apache.tsfile.enums.ColumnCategory;
 import org.apache.tsfile.enums.TSDataType;
@@ -57,6 +60,7 @@
 import java.util.Arrays;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 
 public class ConsensusLogToTabletConverterTest {
 
@@ -393,6 +397,69 @@
   }
 
   @Test
+  public void testConvertRelationalInsertTabletFiltersRowsBeforeColumns() throws Exception {
+    final ConsensusLogToTabletConverter converter =
+        createConverterWithTagFilter("id1 IN (\"id:10\", \"id:12\")", "m1");
+
+    final List<Tablet> tablets = converter.convert(StatementTestUtils.genInsertTabletNode(3, 10));
+
+    Assert.assertEquals(1, tablets.size());
+    final Tablet tablet = tablets.get(0);
+    Assert.assertEquals(2, tablet.getRowSize());
+    Assert.assertArrayEquals(new long[] {10L, 12L}, tablet.getTimestamps());
+    Assert.assertEquals("id:10", toUtf8(((Binary[]) tablet.getValues()[0])[0]));
+    Assert.assertEquals("id:12", toUtf8(((Binary[]) tablet.getValues()[0])[1]));
+    Assert.assertArrayEquals(new double[] {10.0, 12.0}, (double[]) tablet.getValues()[1], 0.0);
+  }
+
+  @Test
+  public void testConvertRelationalInsertRowReturnsEmptyWhenTagDoesNotMatch() throws Exception {
+    final ConsensusLogToTabletConverter converter =
+        createConverterWithTagFilter("id1 = \"missing\"", "m1");
+
+    Assert.assertTrue(converter.convert(StatementTestUtils.genInsertRowNode(7)).isEmpty());
+  }
+
+  @Test
+  public void testReplaySnapshotPreservesTagFilter() throws Exception {
+    final ConsensusLogToTabletConverter converter =
+        createConverterWithTagFilter("id1 IN (\"id:10\", \"id:12\")", "m1");
+    final ConsensusLogToTabletConverter snapshot = converter.snapshotForReplay();
+
+    final List<Tablet> tablets = snapshot.convert(StatementTestUtils.genInsertTabletNode(3, 10));
+
+    Assert.assertEquals(1, tablets.size());
+    Assert.assertEquals(2, tablets.get(0).getRowSize());
+    Assert.assertArrayEquals(new long[] {10L, 12L}, tablets.get(0).getTimestamps());
+    Assert.assertTrue(converter.hasSameReplayProjection(snapshot));
+  }
+
+  @Test
+  public void testReplayProjectionChangesWhenTagFilterChanges() throws Exception {
+    final TablePattern tablePattern =
+        new TablePattern(true, DATABASE_NAME, StatementTestUtils.tableName());
+    final ColumnFilterMatcher columnFilterMatcher =
+        ColumnFilterMatcher.ofSelectedColumnNames(new HashSet<>(Arrays.asList("m1")));
+    final ConsensusLogToTabletConverter original =
+        new ConsensusLogToTabletConverter(
+            null, tablePattern, columnFilterMatcher, TagFilterMatcher.matchAll(), DATABASE_NAME);
+    final TagFilterMatcher tagFilterMatcher =
+        TagFilterMatcher.fromTopicConfig(
+            new TopicConfig(
+                Map.of(
+                    "__system.sql-dialect",
+                    "table",
+                    TopicConstant.TAG_FILTER_KEY,
+                    "id1 = \"id:10\"")));
+    final ConsensusLogToTabletConverter updated =
+        new ConsensusLogToTabletConverter(
+            null, tablePattern, columnFilterMatcher, tagFilterMatcher, DATABASE_NAME);
+
+    Assert.assertFalse(
+        original.snapshotForReplay().hasSameReplayProjection(updated.snapshotForReplay()));
+  }
+
+  @Test
   public void testConvertInsertRowsOfOneDeviceNodeGroupsRowsWithSameSchema()
       throws IllegalPathException {
     final ConsensusLogToTabletConverter converter = createTreeConverter();
@@ -477,6 +544,19 @@
         DATABASE_NAME);
   }
 
+  private static ConsensusLogToTabletConverter createConverterWithTagFilter(
+      final String tagFilter, final String... selectedColumns) throws Exception {
+    final TopicConfig topicConfig =
+        new TopicConfig(
+            Map.of("__system.sql-dialect", "table", TopicConstant.TAG_FILTER_KEY, tagFilter));
+    return new ConsensusLogToTabletConverter(
+        null,
+        new TablePattern(true, DATABASE_NAME, StatementTestUtils.tableName()),
+        ColumnFilterMatcher.ofSelectedColumnNames(new HashSet<>(Arrays.asList(selectedColumns))),
+        TagFilterMatcher.fromTopicConfig(topicConfig),
+        DATABASE_NAME);
+  }
+
   private static ConsensusLogToTabletConverter createTreeConverter() {
     return new ConsensusLogToTabletConverter(new IoTDBTreePattern("root.sg.**"), null, null, null);
   }
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionFilterSnapshotTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionFilterSnapshotTest.java
new file mode 100644
index 0000000..2215517
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionFilterSnapshotTest.java
@@ -0,0 +1,164 @@
+/*
+ * 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.subscription.event.batch;
+
+import org.apache.iotdb.commons.pipe.config.constant.SystemConstant;
+import org.apache.iotdb.commons.subscription.meta.consumer.ConsumerGroupMeta;
+import org.apache.iotdb.commons.subscription.meta.consumer.ConsumerMeta;
+import org.apache.iotdb.commons.subscription.meta.topic.TopicMeta;
+import org.apache.iotdb.db.i18n.DataNodeMiscMessages;
+import org.apache.iotdb.db.subscription.agent.SubscriptionAgent;
+import org.apache.iotdb.db.subscription.broker.SubscriptionPrefetchingTabletQueue;
+import org.apache.iotdb.db.subscription.tagfilter.TabletTagFilter;
+import org.apache.iotdb.db.subscription.tagfilter.TagFilterEvaluationException;
+import org.apache.iotdb.rpc.subscription.config.ConsumerConstant;
+import org.apache.iotdb.rpc.subscription.config.TopicConfig;
+import org.apache.iotdb.rpc.subscription.config.TopicConstant;
+
+import org.apache.tsfile.write.record.Tablet;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.util.Collections;
+import java.util.Map;
+
+public class SubscriptionFilterSnapshotTest {
+
+  private static final String TOPIC = "filter_snapshot_test_topic";
+  private static final String GROUP = "filter_snapshot_test_group";
+
+  private TopicMeta topicMeta;
+  private SubscriptionPrefetchingTabletQueue queue;
+
+  @Before
+  public void setUp() {
+    topicMeta =
+        new TopicMeta(
+            TOPIC,
+            1L,
+            Collections.singletonMap(
+                SystemConstant.SQL_DIALECT_KEY, SystemConstant.SQL_DIALECT_TABLE_VALUE));
+    Assert.assertNull(SubscriptionAgent.topic().handleSingleTopicMetaChanges(topicMeta));
+    Assert.assertNull(
+        SubscriptionAgent.consumer()
+            .handleSingleConsumerGroupMetaChanges(
+                new ConsumerGroupMeta(
+                    GROUP,
+                    1L,
+                    new ConsumerMeta(
+                        "consumer",
+                        1L,
+                        Collections.singletonMap(
+                            ConsumerConstant.SQL_DIALECT_KEY,
+                            SystemConstant.SQL_DIALECT_TABLE_VALUE)))));
+    queue = Mockito.mock(SubscriptionPrefetchingTabletQueue.class);
+    Mockito.when(queue.getTopicName()).thenReturn(TOPIC);
+    Mockito.when(queue.getConsumerGroupId()).thenReturn(GROUP);
+  }
+
+  @After
+  public void tearDown() {
+    Assert.assertNull(SubscriptionAgent.consumer().handleDropConsumerGroup(GROUP));
+    Assert.assertNull(SubscriptionAgent.topic().handleDropTopic(TOPIC, true));
+  }
+
+  @Test
+  public void testOwnerAndRetentionUpdatesPreserveBatchButFilterUpdateRotatesIt() {
+    final SubscriptionFilterSnapshot snapshot = SubscriptionFilterSnapshot.capture(queue);
+    updateTopic(
+        Map.of(
+            TopicConstant.OWNER_ID_KEY, "owner-1",
+            TopicConstant.OWNER_EPOCH_KEY, "1",
+            TopicConstant.RETENTION_MS_KEY, "60000"));
+    Assert.assertTrue(snapshot.isCurrent(queue));
+
+    updateTopic(Collections.singletonMap(TopicConstant.TAG_FILTER_KEY, "false"));
+    Assert.assertFalse(snapshot.isCurrent(queue));
+    Assert.assertTrue(snapshot.getTagFilterMatcher().isMatchAll());
+    final SubscriptionFilterSnapshot updated = SubscriptionFilterSnapshot.capture(queue);
+    Assert.assertTrue(updated.isCurrent(queue));
+    final Tablet tablet =
+        new Tablet(
+            "weather",
+            Collections.emptyList(),
+            Collections.emptyList(),
+            Collections.emptyList(),
+            1);
+    tablet.addTimestamp(0, 1L);
+    Assert.assertNull(TabletTagFilter.filter(tablet, updated.getTagFilterMatcher()));
+  }
+
+  @Test
+  public void testMissingSchemaReportsTargetAndLocalCacheReason() {
+    final String database = "missing_snapshot_database";
+    final String table = "missing_table";
+    updateTopic(
+        Map.of(
+            TopicConstant.DATABASE_KEY, database,
+            TopicConstant.TABLE_KEY, table,
+            TopicConstant.TAG_FILTER_KEY, "region = \"north\""));
+    try {
+      SubscriptionFilterSnapshot.capture(queue);
+      Assert.fail("Expected unavailable table schema to fail closed");
+    } catch (final TagFilterEvaluationException e) {
+      Assert.assertTrue(
+          e.getMessage(),
+          e.getMessage()
+              .contains(
+                  String.format(
+                      DataNodeMiscMessages
+                          .EXCEPTION_TABLE_SCHEMA_ARG_ARG_IS_ABSENT_FROM_THIS_DATANODE_S_TABLE_CACHE_THE_TABLE_MAY_NOT_EXIST_OR_ITS_SCHEMA_HAS_NOT_BEEN_SYNCHRONIZED_YET_23975BDF,
+                      database,
+                      table)));
+    }
+  }
+
+  @Test
+  public void testColumnBindingFailureRetainsMatchNoneMatcher() {
+    updateTopic(
+        Map.of(
+            TopicConstant.DATABASE_KEY, "missing_snapshot_database",
+            TopicConstant.TABLE_KEY, "missing_table",
+            TopicConstant.COLUMN_FILTER_KEY, "column_name = \"temperature\""));
+    final SubscriptionFilterSnapshot snapshot = SubscriptionFilterSnapshot.capture(queue);
+    Assert.assertFalse(snapshot.getColumnFilterMatcher().isMatchAll());
+    Assert.assertFalse(
+        snapshot
+            .getColumnFilterMatcher()
+            .match("missing_snapshot_database", "missing_table", "temperature"));
+  }
+
+  @Test(expected = TagFilterEvaluationException.class)
+  public void testUnavailableTreeViewProjectorFailsInsteadOfDroppingTablet() {
+    final SubscriptionTreeViewProjector projector =
+        new SubscriptionTreeViewProjector(new TopicConfig());
+    Assert.assertTrue(projector.prepare());
+    Assert.assertFalse(projector.isAvailable());
+    projector.project(null);
+  }
+
+  private void updateTopic(final Map<String, String> attributes) {
+    topicMeta = topicMeta.deepCopyWithUpdatedAttributes(attributes);
+    Assert.assertNull(SubscriptionAgent.topic().handleSingleTopicMetaChanges(topicMeta));
+  }
+}
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeTabletEventBatchTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeTabletEventBatchTest.java
index d0d67f4..d6294ff 100644
--- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeTabletEventBatchTest.java
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeTabletEventBatchTest.java
@@ -21,15 +21,21 @@
 
 import org.apache.iotdb.commons.conf.CommonConfig;
 import org.apache.iotdb.commons.conf.CommonDescriptor;
+import org.apache.iotdb.commons.pipe.config.constant.SystemConstant;
 import org.apache.iotdb.commons.pipe.datastructure.pattern.TablePattern;
+import org.apache.iotdb.commons.subscription.meta.consumer.ConsumerGroupMeta;
+import org.apache.iotdb.commons.subscription.meta.consumer.ConsumerMeta;
+import org.apache.iotdb.commons.subscription.meta.topic.TopicMeta;
 import org.apache.iotdb.db.pipe.event.common.tsfile.PipeTsFileInsertionEvent;
 import org.apache.iotdb.db.pipe.resource.PipeDataNodeResourceManager;
 import org.apache.iotdb.db.pipe.resource.memory.PipeMemoryManager;
 import org.apache.iotdb.db.pipe.resource.memory.PipeMemoryManager.TsFileParserMemoryReservation;
 import org.apache.iotdb.db.storageengine.dataregion.tsfile.TsFileResource;
 import org.apache.iotdb.db.storageengine.dataregion.tsfile.TsFileResourceStatus;
+import org.apache.iotdb.db.subscription.agent.SubscriptionAgent;
 import org.apache.iotdb.db.subscription.broker.SubscriptionPrefetchingTabletQueue;
 import org.apache.iotdb.db.subscription.event.pipe.SubscriptionPipeTabletBatchEvents;
+import org.apache.iotdb.rpc.subscription.config.ConsumerConstant;
 
 import org.apache.tsfile.enums.ColumnCategory;
 import org.apache.tsfile.enums.TSDataType;
@@ -81,6 +87,26 @@
         Mockito.mock(SubscriptionPrefetchingTabletQueue.class);
     Mockito.when(queue.getTopicName()).thenReturn("topic");
     Mockito.when(queue.getConsumerGroupId()).thenReturn("group");
+    Assert.assertNull(
+        SubscriptionAgent.topic()
+            .handleSingleTopicMetaChanges(
+                new TopicMeta(
+                    "topic",
+                    1L,
+                    Collections.singletonMap(
+                        SystemConstant.SQL_DIALECT_KEY, SystemConstant.SQL_DIALECT_TABLE_VALUE))));
+    Assert.assertNull(
+        SubscriptionAgent.consumer()
+            .handleSingleConsumerGroupMetaChanges(
+                new ConsumerGroupMeta(
+                    "group",
+                    1L,
+                    new ConsumerMeta(
+                        "consumer",
+                        1L,
+                        Collections.singletonMap(
+                            ConsumerConstant.SQL_DIALECT_KEY,
+                            SystemConstant.SQL_DIALECT_TABLE_VALUE)))));
     batch =
         new SubscriptionPipeTabletEventBatch(Integer.parseInt(REGION_ID), queue, 20, 1024 * 1024);
   }
@@ -93,6 +119,8 @@
     for (final PipeTsFileInsertionEvent event : tsFileEvents) {
       event.close();
     }
+    Assert.assertNull(SubscriptionAgent.consumer().handleDropConsumerGroup("group"));
+    Assert.assertNull(SubscriptionAgent.topic().handleDropTopic("topic", true));
     config.setPipeTsFileParserMemory(originalParserMemory);
     config.setPipeTsFileParserInFlightMaxNum(originalGlobalLimit);
     config.setPipeTsFileParserInFlightMaxNumPerPipeRegion(originalRegionLimit);
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeTsFileEventBatchCleanupTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeTsFileEventBatchCleanupTest.java
index d4d3609..5723170 100644
--- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeTsFileEventBatchCleanupTest.java
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/event/batch/SubscriptionPipeTsFileEventBatchCleanupTest.java
@@ -33,11 +33,43 @@
 import java.io.File;
 import java.nio.file.Files;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 
 public class SubscriptionPipeTsFileEventBatchCleanupTest {
 
   @Test
+  public void testClosesInnerBatchWhenFilteringProducesNoPayload() throws Exception {
+    final PipeTabletEventTsFileBatch innerBatch = Mockito.mock(PipeTabletEventTsFileBatch.class);
+    final SubscriptionPrefetchingTsFileQueue queue =
+        Mockito.mock(SubscriptionPrefetchingTsFileQueue.class);
+    Mockito.when(innerBatch.isEmpty()).thenReturn(true);
+
+    final SubscriptionPipeTsFileEventBatch batch =
+        new SubscriptionPipeTsFileEventBatch(1, queue, 1, 1, innerBatch);
+
+    Assert.assertTrue(batch.generateSubscriptionEvents().isEmpty());
+    Mockito.verify(innerBatch).close();
+  }
+
+  @Test
+  public void testClosesInnerBatchWhenSealingProducesNoFile() throws Exception {
+    final PipeTabletEventTsFileBatch innerBatch = Mockito.mock(PipeTabletEventTsFileBatch.class);
+    final SubscriptionPrefetchingTsFileQueue queue =
+        Mockito.mock(SubscriptionPrefetchingTsFileQueue.class);
+    Mockito.when(innerBatch.isEmpty()).thenReturn(false);
+    Mockito.when(innerBatch.sealTsFiles()).thenReturn(Collections.emptyList());
+
+    final SubscriptionPipeTsFileEventBatch batch =
+        new SubscriptionPipeTsFileEventBatch(1, queue, 1, 1, innerBatch);
+
+    Assert.assertTrue(batch.generateSubscriptionEvents().isEmpty());
+    Mockito.verify(innerBatch).decreaseEventsReferenceCount(batch.getClass().getName(), true);
+    Mockito.verify(innerBatch).onSuccess();
+    Mockito.verify(innerBatch).close();
+  }
+
+  @Test
   public void testDeletesSealedFilesAfterAllSharedEventsAreCleaned() throws Exception {
     final File temporaryDirectory =
         Files.createTempDirectory("subscription-tsfile-cleanup").toFile();
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/tagfilter/TabletTagFilterTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/tagfilter/TabletTagFilterTest.java
new file mode 100644
index 0000000..f7c8d02
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/tagfilter/TabletTagFilterTest.java
@@ -0,0 +1,182 @@
+/*
+ * 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.subscription.tagfilter;
+
+import org.apache.iotdb.db.pipe.sink.util.sorter.PipeTableModelTabletEventSorter;
+import org.apache.iotdb.rpc.subscription.config.TopicConfig;
+import org.apache.iotdb.rpc.subscription.config.TopicConstant;
+
+import org.apache.tsfile.enums.ColumnCategory;
+import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.utils.BitMap;
+import org.apache.tsfile.write.record.Tablet;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class TabletTagFilterTest {
+
+  @Test
+  public void testPrefixSharesColumnDataWithoutChangingSourceWhenSorted() throws Exception {
+    final Tablet tablet = createTablet();
+    tablet.addValue(1, 0, "north");
+    tablet.addValue(1, 1, "d1");
+    tablet.getTimestamps()[0] = 2;
+    tablet.getTimestamps()[1] = 1;
+    final Tablet filtered =
+        TabletTagFilter.filter(tablet, matcher("region IS NOT NULL AND region != \"North\""));
+    Assert.assertNotNull(filtered);
+    Assert.assertEquals(2, filtered.getRowSize());
+    Assert.assertEquals(4, tablet.getRowSize());
+    Assert.assertSame(tablet.getSchemas(), filtered.getSchemas());
+    Assert.assertSame(tablet.getColumnTypes(), filtered.getColumnTypes());
+    Assert.assertSame(tablet.getTimestamps(), filtered.getTimestamps());
+    Assert.assertSame(tablet.getValues()[0], filtered.getValues()[0]);
+    Assert.assertNotSame(tablet.getValues(), filtered.getValues());
+    Assert.assertNotSame(tablet.getBitMaps(), filtered.getBitMaps());
+
+    // Sorting replaces columns and bitmap entries in place, so the outer arrays must be private.
+    final Object originalColumn = tablet.getValues()[0];
+    final BitMap originalBitMap = tablet.getBitMaps()[0];
+    new PipeTableModelTabletEventSorter(filtered).sortAndDeduplicateByDevIdTimestamp();
+    Assert.assertEquals(1, filtered.getTimestamp(0));
+    Assert.assertNotSame(originalColumn, filtered.getValues()[0]);
+    Assert.assertEquals(4, tablet.getRowSize());
+    Assert.assertSame(originalColumn, tablet.getValues()[0]);
+    Assert.assertSame(originalBitMap, tablet.getBitMaps()[0]);
+    Assert.assertEquals("north", tablet.getValue(0, 0).toString());
+    Assert.assertEquals(2, tablet.getTimestamp(0));
+  }
+
+  @Test
+  public void testCompactionRetainsAllNullColumnsWithoutValueArrays() throws Exception {
+    final Tablet tablet = createTablet();
+    tablet.getValues()[2] = null;
+    final BitMap allNull = new BitMap(tablet.getRowSize());
+    allNull.markAll();
+    tablet.getBitMaps()[2] = allNull;
+
+    final Tablet filtered =
+        TabletTagFilter.filter(tablet, matcher("region = \"south\" OR region IS NULL"));
+    Assert.assertNotNull(filtered);
+    Assert.assertEquals(2, filtered.getRowSize());
+    Assert.assertNull(filtered.getValues()[2]);
+    Assert.assertTrue(filtered.isNull(0, 2));
+    Assert.assertTrue(filtered.isNull(1, 2));
+    Assert.assertSame(tablet.getSchemas(), filtered.getSchemas());
+    Assert.assertSame(tablet.getColumnTypes(), filtered.getColumnTypes());
+  }
+
+  @Test
+  public void testFilterRowsAndPreserveValuesAndNulls() throws Exception {
+    final Tablet filtered =
+        TabletTagFilter.filter(createTablet(), matcher("region = \"south\" OR region IS NULL"));
+
+    Assert.assertNotNull(filtered);
+    Assert.assertEquals(2, filtered.getRowSize());
+    Assert.assertEquals(2L, filtered.getTimestamp(0));
+    Assert.assertEquals(3L, filtered.getTimestamp(1));
+    Assert.assertEquals("south", filtered.getValue(0, 0).toString());
+    Assert.assertTrue(filtered.isNull(1, 0));
+    Assert.assertEquals("d2", filtered.getValue(0, 1).toString());
+    Assert.assertEquals("d3", filtered.getValue(1, 1).toString());
+    Assert.assertEquals(20.0, (Double) filtered.getValue(0, 2), 0.0);
+    Assert.assertEquals(30.0, (Double) filtered.getValue(1, 2), 0.0);
+    Assert.assertEquals(createTablet().getColumnTypes(), filtered.getColumnTypes());
+  }
+
+  @Test
+  public void testComparisonIsCaseSensitive() throws Exception {
+    final Tablet filtered = TabletTagFilter.filter(createTablet(), matcher("region = \"north\""));
+
+    Assert.assertNotNull(filtered);
+    Assert.assertEquals(1, filtered.getRowSize());
+    Assert.assertEquals(1L, filtered.getTimestamp(0));
+  }
+
+  @Test
+  public void testUnknownOrNonTagColumnFailsClosed() throws Exception {
+    assertFailsClosed("missing = \"x\"");
+    assertFailsClosed("temperature = \"10\"");
+  }
+
+  @Test
+  public void testNoMatchedRowReturnsNull() throws Exception {
+    Assert.assertNull(TabletTagFilter.filter(createTablet(), matcher("region = \"east\"")));
+  }
+
+  @Test
+  public void testTrivialFilterReturnsOriginalTablet() throws Exception {
+    final Tablet tablet = createTablet();
+
+    Assert.assertSame(tablet, TabletTagFilter.filter(tablet, matcher(" TRUE ")));
+    Assert.assertNull(TabletTagFilter.filter(tablet, TagFilterMatcher.matchNone()));
+  }
+
+  private static TagFilterMatcher matcher(final String filter) throws Exception {
+    final Map<String, String> attributes = new HashMap<>();
+    attributes.put("__system.sql-dialect", "table");
+    attributes.put(TopicConstant.TAG_FILTER_KEY, filter);
+    return TagFilterMatcher.fromTopicConfig(new TopicConfig(attributes));
+  }
+
+  private static void assertFailsClosed(final String filter) throws Exception {
+    try {
+      TabletTagFilter.filter(createTablet(), matcher(filter));
+      Assert.fail("Expected tag-filter schema binding failure: " + filter);
+    } catch (final TagFilterEvaluationException e) {
+      Assert.assertTrue(e.getMessage(), e.getMessage().contains("Tag-filter"));
+    }
+  }
+
+  private static Tablet createTablet() {
+    final List<String> columnNames = Arrays.asList("region", "device", "temperature");
+    final List<TSDataType> dataTypes =
+        Arrays.asList(TSDataType.STRING, TSDataType.STRING, TSDataType.DOUBLE);
+    final List<ColumnCategory> categories =
+        Arrays.asList(ColumnCategory.TAG, ColumnCategory.TAG, ColumnCategory.FIELD);
+    final Tablet tablet = new Tablet("weather", columnNames, dataTypes, categories, 4);
+
+    tablet.addTimestamp(0, 1L);
+    tablet.addValue(0, 0, "north");
+    tablet.addValue(0, 1, "d1");
+    tablet.addValue(0, 2, 10.0);
+
+    tablet.addTimestamp(1, 2L);
+    tablet.addValue(1, 0, "south");
+    tablet.addValue(1, 1, "d2");
+    tablet.addValue(1, 2, 20.0);
+
+    tablet.addTimestamp(2, 3L);
+    tablet.addValue(2, 1, "d3");
+    tablet.addValue(2, 2, 30.0);
+
+    tablet.addTimestamp(3, 4L);
+    tablet.addValue(3, 0, "North");
+    tablet.addValue(3, 1, "d4");
+    tablet.addValue(3, 2, 40.0);
+    tablet.setRowSize(4);
+    return tablet;
+  }
+}
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterParserTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterParserTest.java
new file mode 100644
index 0000000..c8e49da
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/subscription/tagfilter/TagFilterParserTest.java
@@ -0,0 +1,122 @@
+/*
+ * 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.subscription.tagfilter;
+
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Expression;
+import org.apache.iotdb.rpc.subscription.exception.SubscriptionException;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class TagFilterParserTest {
+
+  private static final TagFilterParser PARSER = new TagFilterParser();
+
+  @Test
+  public void testComparisonIsCaseSensitive() throws Exception {
+    Assert.assertTrue(evaluate("region = \"north\"", Map.of("region", "north")));
+    Assert.assertFalse(evaluate("region = \"north\"", Map.of("region", "North")));
+    Assert.assertTrue(evaluate("region != \"north\"", Map.of("region", "North")));
+  }
+
+  @Test
+  public void testQuotesInTagValues() throws Exception {
+    Assert.assertTrue(evaluate("region = \"O'Reilly\"", Map.of("region", "O'Reilly")));
+    Assert.assertFalse(evaluate("region = \"O'Reilly\"", Map.of("region", "O''Reilly")));
+    Assert.assertTrue(evaluate("region = \"a''b\"", Map.of("region", "a''b")));
+    Assert.assertTrue(evaluate("region = \"a\"\"\"\"b\"", Map.of("region", "a\"\"b")));
+    Assert.assertFalse(evaluate("region = \"a\"\"\"\"b\"", Map.of("region", "a\"b")));
+    Assert.assertTrue(evaluate("region = \"\"\"quoted\"\"\"", Map.of("region", "\"quoted\"")));
+    Assert.assertTrue(
+        evaluate(
+            "region = \"O'Reilly \"\"A\"\"\"\"B\"\"\"", Map.of("region", "O'Reilly \"A\"\"B\"")));
+  }
+
+  @Test
+  public void testQuotesInInLikeAndRegexp() throws Exception {
+    final String inFilter = "region IN (\"O'Reilly\", \"a\"\"\"\"b\")";
+    Assert.assertTrue(evaluate(inFilter, Map.of("region", "O'Reilly")));
+    Assert.assertTrue(evaluate(inFilter, Map.of("region", "a\"\"b")));
+    Assert.assertFalse(evaluate(inFilter, Map.of("region", "a\"b")));
+    Assert.assertTrue(
+        evaluate("region LIKE \"O'Reilly \"\"%\"\"\"", Map.of("region", "O'Reilly \"north\"")));
+    Assert.assertTrue(
+        evaluate(
+            "region REGEXP \"^O'Reilly \"\"[A-Z]+\"\"$\"", Map.of("region", "O'Reilly \"NORTH\"")));
+  }
+
+  @Test
+  public void testInLikeRegexpAndBooleanOperators() throws Exception {
+    Assert.assertTrue(
+        evaluate(
+            "region IN (\"north\", \"west\") AND device LIKE \"d_%\"",
+            Map.of("region", "north", "device", "d_1")));
+    Assert.assertTrue(
+        evaluate(
+            "region NOT REGEXP \"south|east\" OR NOT device = \"d2\"",
+            Map.of("region", "north", "device", "d2")));
+    Assert.assertTrue(evaluate("device LIKE \"d!_%\" ESCAPE \"!\"", Map.of("device", "d_sensor")));
+    Assert.assertTrue(evaluate("device LIKE \"d_%\"", Map.of("device", "d\n1")));
+  }
+
+  @Test
+  public void testNullUsesThreeValuedLogic() throws Exception {
+    final Map<String, String> values = new HashMap<>();
+    values.put("region", null);
+
+    Assert.assertTrue(evaluate("region IS NULL", values));
+    Assert.assertFalse(evaluate("region IS NOT NULL", values));
+    Assert.assertFalse(evaluate("region = \"north\"", values));
+    Assert.assertFalse(evaluate("NOT region = \"north\"", values));
+    Assert.assertTrue(evaluate("region = \"north\" OR true", values));
+    Assert.assertFalse(evaluate("region = \"north\" AND true", values));
+    Assert.assertTrue(evaluate("NOT (region = \"north\" AND false)", values));
+    Assert.assertFalse(evaluate("NOT (region = \"north\" OR true)", values));
+    Assert.assertTrue(evaluate("NOT (false AND region = \"north\")", values));
+    Assert.assertFalse(evaluate("NOT (true OR region = \"north\")", values));
+  }
+
+  @Test
+  public void testRejectInvalidExpressions() {
+    assertRejected("", "tag-filter should not be empty");
+    assertRejected("region > \"north\"", "unsupported comparison operator");
+    assertRejected("lower(region) = \"north\"", "expected column predicate operator");
+    assertRejected("region = other_tag", "expected string literal");
+    assertRejected("region REGEXP \"[\"", "illegal REGEXP pattern");
+  }
+
+  private static boolean evaluate(final String filter, final Map<String, String> values)
+      throws SubscriptionException {
+    final Expression expression = PARSER.parseAndValidate(filter);
+    return TagFilterEvaluator.evaluate(expression, identifier -> values.get(identifier.getValue()));
+  }
+
+  private static void assertRejected(final String filter, final String expectedMessagePart) {
+    try {
+      PARSER.parseAndValidate(filter);
+      Assert.fail("Expected tag-filter to be rejected: " + filter);
+    } catch (final SubscriptionException e) {
+      Assert.assertTrue(e.getMessage(), e.getMessage().contains(expectedMessagePart));
+    }
+  }
+}
diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/schema/column/ColumnHeaderConstant.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/schema/column/ColumnHeaderConstant.java
index d50be22..d4a46e4 100644
--- a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/schema/column/ColumnHeaderConstant.java
+++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/schema/column/ColumnHeaderConstant.java
@@ -134,6 +134,8 @@
   // column names for show topics statement
   public static final String TOPIC_NAME = "TopicName";
   public static final String TOPIC_CONFIGS = "TopicConfigs";
+  public static final String TAG_FILTER_STATUS = "TagFilterStatus";
+  public static final String TAG_FILTER_MESSAGE = "TagFilterMessage";
   public static final String TOPIC = "Topic";
   public static final String CREATE_TOPIC = "Create Topic";
 
@@ -696,6 +698,13 @@
           new ColumnHeader(TOPIC_NAME, TSDataType.TEXT),
           new ColumnHeader(TOPIC_CONFIGS, TSDataType.TEXT));
 
+  public static final List<ColumnHeader> showTableTopicColumnHeaders =
+      ImmutableList.of(
+          new ColumnHeader(TOPIC_NAME, TSDataType.TEXT),
+          new ColumnHeader(TOPIC_CONFIGS, TSDataType.TEXT),
+          new ColumnHeader(TAG_FILTER_STATUS, TSDataType.TEXT),
+          new ColumnHeader(TAG_FILTER_MESSAGE, TSDataType.TEXT));
+
   public static final List<ColumnHeader> showCreateTopicColumnHeaders =
       ImmutableList.of(
           new ColumnHeader(TOPIC, TSDataType.TEXT),
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 c68bcdd..086976d 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
@@ -25,6 +25,7 @@
 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.config.TopicProperty;
 import org.apache.iotdb.rpc.subscription.i18n.SubscriptionMessages;
 
 import org.apache.tsfile.utils.PublicBAOS;
@@ -73,11 +74,11 @@
       final String topicName, final long creationTime, final Map<String, String> topicAttributes) {
     this.topicName = topicName;
     this.creationTime = creationTime;
-    this.config = new TopicConfig(topicAttributes);
+    this.config = new TopicConfig(canonicalizeTopicAttributes(topicAttributes));
     this.ownerEpoch = -1L;
     this.maxOwnerEpoch = -1L;
     this.ownerLastTransferTimeMs = -1L;
-    initOwnerFromTopicAttributes(topicAttributes);
+    initOwnerFromTopicAttributes(this.config.getAttribute());
 
     this.subscribedConsumerGroupIds = new HashSet<>();
   }
@@ -99,12 +100,18 @@
   }
 
   public TopicMeta deepCopyWithUpdatedAttributes(final Map<String, String> updatedAttributes) {
-    final Map<String, String> copiedAttributes = new HashMap<>(config.getAttribute());
+    final Map<String, String> copiedAttributes = canonicalizeTopicAttributes(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)) {
+      boolean ownerChanged = false;
+      boolean leaseUpdated = false;
+      for (final Map.Entry<String, String> entry : updatedAttributes.entrySet()) {
+        final String key = canonicalizeTopicAttributeKey(entry.getKey());
+        copiedAttributes.put(key, entry.getValue());
+        ownerChanged |=
+            TopicConstant.OWNER_ID_KEY.equals(key) || TopicConstant.OWNER_EPOCH_KEY.equals(key);
+        leaseUpdated |= TopicConstant.OWNER_LEASE_DURATION_MS_KEY.equals(key);
+      }
+      if (ownerChanged && !leaseUpdated) {
         copiedAttributes.remove(TopicConstant.OWNER_LEASE_DURATION_MS_KEY);
       }
     }
@@ -310,6 +317,7 @@
       final String value = ReadWriteIOUtils.readString(inputStream);
       topicMeta.config.getAttribute().put(key, value);
     }
+    canonicalizeTopicAttributesInPlace(topicMeta.config.getAttribute());
 
     size = ReadWriteIOUtils.readInt(inputStream);
     for (int i = 0; i < size; i++) {
@@ -333,6 +341,7 @@
       final String value = ReadWriteIOUtils.readString(byteBuffer);
       topicMeta.config.getAttribute().put(key, value);
     }
+    canonicalizeTopicAttributesInPlace(topicMeta.config.getAttribute());
 
     size = ReadWriteIOUtils.readInt(byteBuffer);
     for (int i = 0; i < size; i++) {
@@ -396,6 +405,30 @@
     }
   }
 
+  // SQL accepts case-insensitive built-in keys, while runtime getters use canonical keys. Apply
+  // the same normalization on creation, ALTER merges, and deserialization so an ALTER cannot leave
+  // both "Tag-Filter" and "tag-filter" with different values. Extension keys remain unchanged.
+  private static Map<String, String> canonicalizeTopicAttributes(
+      final Map<String, String> topicAttributes) {
+    final Map<String, String> canonicalAttributes = new HashMap<>();
+    if (Objects.isNull(topicAttributes)) {
+      return canonicalAttributes;
+    }
+    topicAttributes.forEach(
+        (key, value) -> canonicalAttributes.put(canonicalizeTopicAttributeKey(key), value));
+    return canonicalAttributes;
+  }
+
+  private static void canonicalizeTopicAttributesInPlace(final Map<String, String> attributes) {
+    final Map<String, String> canonicalAttributes = canonicalizeTopicAttributes(attributes);
+    attributes.clear();
+    attributes.putAll(canonicalAttributes);
+  }
+
+  private static String canonicalizeTopicAttributeKey(final String key) {
+    return TopicProperty.canonicalizeKey(key);
+  }
+
   private void initOwnerFromTopicAttributes(final Map<String, String> topicAttributes) {
     final TopicConfig topicConfig = new TopicConfig(topicAttributes);
 
@@ -455,6 +488,8 @@
       extractorAttributes.putAll(config.getAttributesWithSourceDatabaseAndTableName());
       // column-filter is evaluated by subscription runtime on DataNode.
       extractorAttributes.putAll(config.getAttributesWithSourceColumnFilter());
+      // tag-filter is evaluated by subscription runtime on DataNode.
+      extractorAttributes.putAll(config.getAttributesWithSourceTagFilter());
     } else {
       // tree model: path or pattern
       extractorAttributes.putAll(config.getAttributesWithSourcePathOrPattern());
diff --git a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/subscription/topic/TopicMetaTest.java b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/subscription/topic/TopicMetaTest.java
new file mode 100644
index 0000000..26af024
--- /dev/null
+++ b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/subscription/topic/TopicMetaTest.java
@@ -0,0 +1,72 @@
+/*
+ * 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.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.util.Map;
+
+public class TopicMetaTest {
+
+  @Test
+  public void testMixedCaseAlterReplacesBuiltInKeyAndPreservesExtensionKeys() throws Exception {
+    final TopicMeta original =
+        new TopicMeta(
+            "topic",
+            1L,
+            Map.of("Tag-Filter", "region = \"north\"", "source.CustomOption", "CaseSensitive"));
+    final TopicMeta updated =
+        original.deepCopyWithUpdatedAttributes(Map.of(" TAG-FILTER ", "region = \"North\""));
+    Assert.assertEquals(2, updated.getConfig().getAttribute().size());
+    Assert.assertEquals("region = \"North\"", updated.getConfig().getTagFilter());
+    Assert.assertEquals(
+        "CaseSensitive", updated.getConfig().getAttribute().get("source.CustomOption"));
+    Assert.assertEquals("region = \"north\"", original.getConfig().getTagFilter());
+
+    Assert.assertEquals(updated, TopicMeta.deserialize(updated.serialize()));
+    final ByteArrayOutputStream output = new ByteArrayOutputStream();
+    updated.serialize(output);
+    Assert.assertEquals(
+        updated, TopicMeta.deserialize(new ByteArrayInputStream(output.toByteArray())));
+  }
+
+  @Test
+  public void testDeserializationCanonicalizesLegacyMixedCaseAttributes() throws Exception {
+    final TopicMeta topic = new TopicMeta("topic", 1L, Map.of());
+    // Simulate metadata written before key canonicalization was introduced.
+    topic.getConfig().getAttribute().put("Tag-Filter", "region = \"North\"");
+    final TopicMeta fromBuffer = TopicMeta.deserialize(topic.serialize());
+    final ByteArrayOutputStream output = new ByteArrayOutputStream();
+    topic.serialize(output);
+    final TopicMeta fromStream =
+        TopicMeta.deserialize(new ByteArrayInputStream(output.toByteArray()));
+    for (final TopicMeta restored : new TopicMeta[] {fromBuffer, fromStream}) {
+      Assert.assertEquals(
+          Map.of(TopicConstant.TAG_FILTER_KEY, "region = \"North\""),
+          restored.getConfig().getAttribute());
+    }
+  }
+}