This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 803ed5c8ce [core] Maintain primary-key full-text index archives (#8651)
803ed5c8ce is described below

commit 803ed5c8ce0453c35c697110e03be15a6a5dd53a
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Jul 15 15:52:41 2026 +0800

    [core] Maintain primary-key full-text index archives (#8651)
    
    Build on #8649 by adding LSM-style incremental maintenance for
    primary-key full-text index archives.
---
 .../main/java/org/apache/paimon/CoreOptions.java   |  57 +++
 .../java/org/apache/paimon/KeyValueFileStore.java  |   1 +
 .../pk/BucketedPrimaryKeyIndexMaintainer.java      | 148 +++++-
 .../paimon/index/pk/PrimaryKeyIndexDefinition.java |   3 +-
 .../index/pk/PrimaryKeyIndexDefinitions.java       |  20 +-
 .../BucketedFullTextIndexMaintainer.java           | 557 +++++++++++++++++++++
 .../org/apache/paimon/schema/SchemaManager.java    |   3 +-
 .../org/apache/paimon/schema/SchemaValidation.java |  60 ++-
 .../pk/BucketedPrimaryKeyIndexMaintainerTest.java  | 130 +++++
 .../index/pk/PrimaryKeyIndexDefinitionsTest.java   |  32 ++
 .../BucketedFullTextIndexMaintainerTest.java       | 343 +++++++++++++
 .../paimon/operation/PrimaryKeyIndexWriteTest.java |  23 +
 .../PrimaryKeyFullTextIndexValidationTest.java     | 243 +++++++++
 .../apache/paimon/schema/SchemaManagerTest.java    |  36 ++
 14 files changed, 1644 insertions(+), 12 deletions(-)

diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java 
b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
index 2537fbb793..271e46346e 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -2755,6 +2755,14 @@ public class CoreOptions implements Serializable {
                     .withDescription(
                             "Comma-separated columns indexed by primary-key 
Bitmap indexes.");
 
+    public static final ConfigOption<String> PK_FULL_TEXT_INDEX_COLUMNS =
+            key("pk-full-text.index.columns")
+                    .stringType()
+                    .noDefaultValue()
+                    .withDescription(
+                            "Comma-separated character columns indexed by 
primary-key full-text indexes. "
+                                    + "The first release supports exactly one 
column.");
+
     @Immutable
     public static final ConfigOption<Boolean> PK_CLUSTERING_OVERRIDE =
             key("pk-clustering-override")
@@ -4287,6 +4295,10 @@ public class CoreOptions implements Serializable {
         return options.getOptional(PK_VECTOR_INDEX_COLUMNS).isPresent();
     }
 
+    public boolean primaryKeyFullTextIndexEnabled() {
+        return options.getOptional(PK_FULL_TEXT_INDEX_COLUMNS).isPresent();
+    }
+
     public int primaryKeyIndexCompactionLevelFanout(String column) {
         return 
options.getInteger(primaryKeyIndexCompactionLevelFanoutKey(column), 5);
     }
@@ -4315,6 +4327,10 @@ public class CoreOptions implements Serializable {
         return primaryKeyIndexColumns(PK_BITMAP_INDEX_COLUMNS);
     }
 
+    public List<String> primaryKeyFullTextIndexColumns() {
+        return primaryKeyIndexColumns(PK_FULL_TEXT_INDEX_COLUMNS);
+    }
+
     private List<String> primaryKeyIndexColumns(ConfigOption<String> option) {
         String columns = options.get(option);
         if (columns == null) {
@@ -4331,6 +4347,47 @@ public class CoreOptions implements Serializable {
         return primaryKeySortedIndexOptions(column, "pk-bitmap", 
"bitmap-index.");
     }
 
+    public Options primaryKeyFullTextIndexOptions(String column) {
+        String optionKey = "fields." + column + ".pk-full-text.index.options";
+        TreeMap<String, String> resolved = new TreeMap<>();
+        for (Map.Entry<String, String> entry : 
toConfiguration().toMap().entrySet()) {
+            if (entry.getKey().startsWith("full-text.")) {
+                resolved.put(entry.getKey(), entry.getValue());
+            }
+        }
+
+        String serialized = options.get(optionKey);
+        if (serialized == null || serialized.trim().isEmpty()) {
+            return new Options(resolved);
+        }
+
+        LinkedHashMap<String, String> parsed;
+        try {
+            parsed = JsonSerdeUtil.parseJsonMap(serialized, String.class);
+        } catch (RuntimeException e) {
+            throw new IllegalArgumentException(
+                    optionKey + " must be a JSON object of option key-value 
pairs.", e);
+        }
+
+        for (Map.Entry<String, String> entry : parsed.entrySet()) {
+            String key = entry.getKey();
+            String value = entry.getValue();
+            checkArgument(
+                    key != null && !key.trim().isEmpty(),
+                    "%s contains an empty option key.",
+                    optionKey);
+            checkArgument(value != null, "%s value for key %s must not be 
null.", optionKey, key);
+            String qualifiedKey = key.startsWith("full-text.") ? key : 
"full-text." + key;
+            String previous = resolved.put(qualifiedKey, value);
+            checkArgument(
+                    previous == null || previous.equals(value),
+                    "%s defines conflicting values for %s.",
+                    optionKey,
+                    qualifiedKey);
+        }
+        return new Options(resolved);
+    }
+
     private Options primaryKeySortedIndexOptions(
             String column, String optionFamily, String algorithmPrefix) {
         Options resolved = new Options(toConfiguration().toMap());
diff --git a/paimon-core/src/main/java/org/apache/paimon/KeyValueFileStore.java 
b/paimon-core/src/main/java/org/apache/paimon/KeyValueFileStore.java
index 7531f91a77..b3c06c98c1 100644
--- a/paimon-core/src/main/java/org/apache/paimon/KeyValueFileStore.java
+++ b/paimon-core/src/main/java/org/apache/paimon/KeyValueFileStore.java
@@ -172,6 +172,7 @@ public class KeyValueFileStore extends 
AbstractFileStore<KeyValue> {
         }
         BucketedPrimaryKeyIndexMaintainer.Factory 
primaryKeyIndexMaintainerFactory = null;
         if (options.primaryKeyVectorIndexEnabled()
+                || options.primaryKeyFullTextIndexEnabled()
                 || !options.primaryKeyBTreeIndexColumns().isEmpty()
                 || !options.primaryKeyBitmapIndexColumns().isEmpty()) {
             primaryKeyIndexMaintainerFactory =
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pk/BucketedPrimaryKeyIndexMaintainer.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pk/BucketedPrimaryKeyIndexMaintainer.java
index 938c3ed10e..dd3f244bb7 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/index/pk/BucketedPrimaryKeyIndexMaintainer.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pk/BucketedPrimaryKeyIndexMaintainer.java
@@ -23,6 +23,10 @@ import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.disk.IOManager;
 import org.apache.paimon.index.IndexFileHandler;
 import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.pkfulltext.BucketedFullTextIndexMaintainer;
+import org.apache.paimon.index.pkfulltext.PkFullTextDataFileReader;
+import org.apache.paimon.index.pkfulltext.PkFullTextIndexBuilder;
+import org.apache.paimon.index.pkfulltext.PkFullTextIndexFile;
 import org.apache.paimon.index.pksorted.BucketedSortedIndexMaintainer;
 import org.apache.paimon.index.pksorted.PkSortedDataFileReader;
 import org.apache.paimon.index.pksorted.PkSortedIndexBuilder;
@@ -50,13 +54,16 @@ import static 
org.apache.paimon.utils.Preconditions.checkArgument;
 public final class BucketedPrimaryKeyIndexMaintainer {
 
     @Nullable private final BucketedVectorIndexMaintainer vectorMaintainer;
+    @Nullable private final BucketedFullTextIndexMaintainer fullTextMaintainer;
     private final List<BucketedSortedIndexMaintainer> sortedMaintainers;
     private int nextSortedMaintainerIndex;
 
     private BucketedPrimaryKeyIndexMaintainer(
             @Nullable BucketedVectorIndexMaintainer vectorMaintainer,
+            @Nullable BucketedFullTextIndexMaintainer fullTextMaintainer,
             List<BucketedSortedIndexMaintainer> sortedMaintainers) {
         this.vectorMaintainer = vectorMaintainer;
+        this.fullTextMaintainer = fullTextMaintainer;
         List<BucketedSortedIndexMaintainer> sorted = new 
ArrayList<>(sortedMaintainers);
         
sorted.sort(Comparator.comparingInt(BucketedSortedIndexMaintainer::fieldId));
         this.sortedMaintainers = Collections.unmodifiableList(sorted);
@@ -64,18 +71,33 @@ public final class BucketedPrimaryKeyIndexMaintainer {
 
     public static BucketedPrimaryKeyIndexMaintainer ofVector(
             BucketedVectorIndexMaintainer vectorMaintainer) {
-        return new BucketedPrimaryKeyIndexMaintainer(vectorMaintainer, 
Collections.emptyList());
+        return new BucketedPrimaryKeyIndexMaintainer(
+                vectorMaintainer, null, Collections.emptyList());
+    }
+
+    public static BucketedPrimaryKeyIndexMaintainer ofFullText(
+            BucketedFullTextIndexMaintainer fullTextMaintainer) {
+        return new BucketedPrimaryKeyIndexMaintainer(
+                null, fullTextMaintainer, Collections.emptyList());
     }
 
     public static BucketedPrimaryKeyIndexMaintainer of(
             BucketedVectorIndexMaintainer vectorMaintainer,
             List<BucketedSortedIndexMaintainer> sortedMaintainers) {
-        return new BucketedPrimaryKeyIndexMaintainer(vectorMaintainer, 
sortedMaintainers);
+        return new BucketedPrimaryKeyIndexMaintainer(vectorMaintainer, null, 
sortedMaintainers);
+    }
+
+    public static BucketedPrimaryKeyIndexMaintainer of(
+            @Nullable BucketedVectorIndexMaintainer vectorMaintainer,
+            @Nullable BucketedFullTextIndexMaintainer fullTextMaintainer,
+            List<BucketedSortedIndexMaintainer> sortedMaintainers) {
+        return new BucketedPrimaryKeyIndexMaintainer(
+                vectorMaintainer, fullTextMaintainer, sortedMaintainers);
     }
 
     public static BucketedPrimaryKeyIndexMaintainer ofSorted(
             List<BucketedSortedIndexMaintainer> sortedMaintainers) {
-        return new BucketedPrimaryKeyIndexMaintainer(null, sortedMaintainers);
+        return new BucketedPrimaryKeyIndexMaintainer(null, null, 
sortedMaintainers);
     }
 
     public synchronized void prepareCommit(
@@ -84,6 +106,7 @@ public final class BucketedPrimaryKeyIndexMaintainer {
             boolean waitCompaction)
             throws Exception {
         BucketedVectorIndexMaintainer.VectorIndexCommit vectorCommit = null;
+        BucketedFullTextIndexMaintainer.FullTextIndexCommit fullTextCommit = 
null;
         List<BucketedSortedIndexMaintainer.SortedIndexCommit> sortedCommits = 
new ArrayList<>();
         try {
             if (vectorMaintainer != null) {
@@ -91,6 +114,11 @@ public final class BucketedPrimaryKeyIndexMaintainer {
                         vectorMaintainer.prepareCommit(
                                 appendIncrement, compactIncrement, 
waitCompaction);
             }
+            if (fullTextMaintainer != null) {
+                fullTextCommit =
+                        fullTextMaintainer.prepareCommit(
+                                appendIncrement, compactIncrement, 
waitCompaction);
+            }
 
             if (waitCompaction) {
                 prepareSortedBlocking(appendIncrement, compactIncrement, 
sortedCommits);
@@ -101,6 +129,9 @@ public final class BucketedPrimaryKeyIndexMaintainer {
             if (vectorCommit != null) {
                 mergeVectorCommit(appendIncrement, compactIncrement, 
vectorCommit);
             }
+            if (fullTextCommit != null) {
+                mergeFullTextCommit(appendIncrement, compactIncrement, 
fullTextCommit);
+            }
             for (BucketedSortedIndexMaintainer.SortedIndexCommit sortedCommit 
: sortedCommits) {
                 mergeSortedCommit(appendIncrement, compactIncrement, 
sortedCommit);
             }
@@ -108,6 +139,9 @@ public final class BucketedPrimaryKeyIndexMaintainer {
             for (int i = sortedCommits.size() - 1; i >= 0; i--) {
                 sortedCommits.get(i).abort(failure);
             }
+            if (fullTextCommit != null) {
+                fullTextCommit.abort(failure);
+            }
             if (vectorCommit != null) {
                 vectorCommit.abort(failure);
             }
@@ -219,6 +253,28 @@ public final class BucketedPrimaryKeyIndexMaintainer {
                                         increment.deletedIndexFiles()));
     }
 
+    private static void mergeFullTextCommit(
+            DataIncrement appendIncrement,
+            CompactIncrement compactIncrement,
+            BucketedFullTextIndexMaintainer.FullTextIndexCommit commit) {
+        commit.appendIncrement()
+                .ifPresent(
+                        increment ->
+                                applyIndexIncrement(
+                                        appendIncrement.newIndexFiles(),
+                                        appendIncrement.deletedIndexFiles(),
+                                        increment.newIndexFiles(),
+                                        increment.deletedIndexFiles()));
+        commit.compactIncrement()
+                .ifPresent(
+                        increment ->
+                                applyIndexIncrement(
+                                        compactIncrement.newIndexFiles(),
+                                        compactIncrement.deletedIndexFiles(),
+                                        increment.newIndexFiles(),
+                                        increment.deletedIndexFiles()));
+    }
+
     private static void applyIndexIncrement(
             List<IndexFileMeta> targetNew,
             List<IndexFileMeta> targetDeleted,
@@ -232,6 +288,9 @@ public final class BucketedPrimaryKeyIndexMaintainer {
         if (vectorMaintainer != null && vectorMaintainer.buildNotCompleted()) {
             return true;
         }
+        if (fullTextMaintainer != null && 
fullTextMaintainer.buildNotCompleted()) {
+            return true;
+        }
         return activeSortedMaintainer() != null;
     }
 
@@ -239,6 +298,9 @@ public final class BucketedPrimaryKeyIndexMaintainer {
         if (vectorMaintainer != null) {
             vectorMaintainer.withExecutor(executor);
         }
+        if (fullTextMaintainer != null) {
+            fullTextMaintainer.withExecutor(executor);
+        }
         for (BucketedSortedIndexMaintainer maintainer : sortedMaintainers) {
             maintainer.withExecutor(executor);
         }
@@ -248,6 +310,9 @@ public final class BucketedPrimaryKeyIndexMaintainer {
         if (vectorMaintainer != null) {
             vectorMaintainer.close();
         }
+        if (fullTextMaintainer != null) {
+            fullTextMaintainer.close();
+        }
         for (BucketedSortedIndexMaintainer maintainer : sortedMaintainers) {
             maintainer.close();
         }
@@ -258,20 +323,23 @@ public final class BucketedPrimaryKeyIndexMaintainer {
 
         private final IndexFileHandler handler;
         @Nullable private final BucketedVectorIndexMaintainer.Factory 
vectorFactory;
+        @Nullable private final FullTextDefinitionFactory fullTextFactory;
         private final List<SortedDefinitionFactory> sortedFactories;
 
         private Factory(
                 IndexFileHandler handler,
                 @Nullable BucketedVectorIndexMaintainer.Factory vectorFactory,
+                @Nullable FullTextDefinitionFactory fullTextFactory,
                 List<SortedDefinitionFactory> sortedFactories) {
             this.handler = handler;
             this.vectorFactory = vectorFactory;
+            this.fullTextFactory = fullTextFactory;
             this.sortedFactories = Collections.unmodifiableList(new 
ArrayList<>(sortedFactories));
         }
 
         public static Factory ofVector(BucketedVectorIndexMaintainer.Factory 
vectorFactory) {
             return new Factory(
-                    vectorFactory.indexFileHandler(), vectorFactory, 
Collections.emptyList());
+                    vectorFactory.indexFileHandler(), vectorFactory, null, 
Collections.emptyList());
         }
 
         public static Factory create(
@@ -281,6 +349,7 @@ public final class BucketedPrimaryKeyIndexMaintainer {
             CoreOptions coreOptions = new CoreOptions(schema.options());
             Map<String, DataField> fields = schema.nameToFieldMap();
             BucketedVectorIndexMaintainer.Factory vectorFactory = null;
+            FullTextDefinitionFactory fullTextFactory = null;
             List<SortedDefinitionFactory> sortedFactories = new ArrayList<>();
             for (PrimaryKeyIndexDefinition definition :
                     PrimaryKeyIndexDefinitions.create(schema).definitions()) {
@@ -315,15 +384,27 @@ public final class BucketedPrimaryKeyIndexMaintainer {
                                         definition.compactionLevelFanout(),
                                         
definition.compactionStaleRatioThreshold()));
                         break;
+                    case FULL_TEXT:
+                        checkArgument(
+                                fullTextFactory == null,
+                                "Only one primary-key full-text index is 
supported.");
+                        fullTextFactory =
+                                new FullTextDefinitionFactory(
+                                        readerFactoryBuilder,
+                                        field,
+                                        definition.options(),
+                                        definition.compactionLevelFanout(),
+                                        
definition.compactionStaleRatioThreshold());
+                        break;
                     default:
                         throw new IllegalArgumentException(
                                 "Unsupported primary-key index family " + 
definition.family());
                 }
             }
             checkArgument(
-                    vectorFactory != null || !sortedFactories.isEmpty(),
+                    vectorFactory != null || fullTextFactory != null || 
!sortedFactories.isEmpty(),
                     "No primary-key index definition is configured.");
-            return new Factory(handler, vectorFactory, sortedFactories);
+            return new Factory(handler, vectorFactory, fullTextFactory, 
sortedFactories);
         }
 
         public IndexFileHandler indexFileHandler() {
@@ -355,6 +436,11 @@ public final class BucketedPrimaryKeyIndexMaintainer {
                             ? null
                             : vectorFactory.create(
                                     partition, bucket, dataFiles, payloads, 
executor);
+            BucketedFullTextIndexMaintainer fullText =
+                    fullTextFactory == null
+                            ? null
+                            : fullTextFactory.create(
+                                    handler, partition, bucket, dataFiles, 
payloads, executor);
             List<BucketedSortedIndexMaintainer> sorted = new ArrayList<>();
             for (SortedDefinitionFactory factory : sortedFactories) {
                 sorted.add(
@@ -362,7 +448,55 @@ public final class BucketedPrimaryKeyIndexMaintainer {
                                 handler, partition, bucket, dataFiles, 
payloads, executor,
                                 ioManager));
             }
-            return new BucketedPrimaryKeyIndexMaintainer(vector, sorted);
+            return new BucketedPrimaryKeyIndexMaintainer(vector, fullText, 
sorted);
+        }
+
+        private static final class FullTextDefinitionFactory {
+
+            private final KeyValueFileReaderFactory.Builder 
readerFactoryBuilder;
+            private final DataField field;
+            private final org.apache.paimon.options.Options options;
+            private final int compactionLevelFanout;
+            private final double compactionStaleRatioThreshold;
+
+            private FullTextDefinitionFactory(
+                    KeyValueFileReaderFactory.Builder readerFactoryBuilder,
+                    DataField field,
+                    org.apache.paimon.options.Options options,
+                    int compactionLevelFanout,
+                    double compactionStaleRatioThreshold) {
+                this.readerFactoryBuilder = readerFactoryBuilder;
+                this.field = field;
+                this.options = options;
+                this.compactionLevelFanout = compactionLevelFanout;
+                this.compactionStaleRatioThreshold = 
compactionStaleRatioThreshold;
+            }
+
+            private BucketedFullTextIndexMaintainer create(
+                    IndexFileHandler handler,
+                    BinaryRow partition,
+                    int bucket,
+                    List<DataFileMeta> restoredDataFiles,
+                    List<IndexFileMeta> restoredPayloads,
+                    ExecutorService executor) {
+                PkFullTextIndexFile indexFile = 
handler.pkFullTextIndex(partition, bucket);
+                PkFullTextIndexBuilder builder =
+                        new PkFullTextIndexBuilder(
+                                indexFile,
+                                new PkFullTextDataFileReader.Factory(
+                                        readerFactoryBuilder, partition, 
bucket, field),
+                                field,
+                                options);
+                return new BucketedFullTextIndexMaintainer(
+                        field.id(),
+                        indexFile,
+                        builder,
+                        compactionLevelFanout,
+                        compactionStaleRatioThreshold,
+                        restoredDataFiles,
+                        restoredPayloads,
+                        executor);
+            }
         }
 
         private static final class SortedDefinitionFactory {
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinition.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinition.java
index 266f529051..f5d47f8b60 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinition.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinition.java
@@ -27,7 +27,8 @@ public class PrimaryKeyIndexDefinition {
     public enum Family {
         VECTOR,
         BTREE,
-        BITMAP
+        BITMAP,
+        FULL_TEXT
     }
 
     private final String column;
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinitions.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinitions.java
index 9d56db1e67..0ac0353e04 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinitions.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinitions.java
@@ -46,10 +46,12 @@ public class PrimaryKeyIndexDefinitions {
         List<String> vectorColumns = options.primaryKeyVectorIndexColumns();
         List<String> btreeColumns = options.primaryKeyBTreeIndexColumns();
         List<String> bitmapColumns = options.primaryKeyBitmapIndexColumns();
+        List<String> fullTextColumns = 
options.primaryKeyFullTextIndexColumns();
         validateNoDuplicates(vectorColumns, 
CoreOptions.PK_VECTOR_INDEX_COLUMNS.key());
         validateNoDuplicates(btreeColumns, 
CoreOptions.PK_BTREE_INDEX_COLUMNS.key());
         validateNoDuplicates(bitmapColumns, 
CoreOptions.PK_BITMAP_INDEX_COLUMNS.key());
-        validateOneIndexPerColumn(vectorColumns, btreeColumns, bitmapColumns);
+        validateNoDuplicates(fullTextColumns, 
CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key());
+        validateOneIndexPerColumn(vectorColumns, btreeColumns, bitmapColumns, 
fullTextColumns);
         List<PrimaryKeyIndexDefinition> definitions = new ArrayList<>();
 
         for (DataField field : schema.fields()) {
@@ -84,6 +86,16 @@ public class PrimaryKeyIndexDefinitions {
                                 PrimaryKeyIndexDefinition.Family.VECTOR,
                                 
options.primaryKeyIndexCompactionLevelFanout(column),
                                 
options.primaryKeyIndexCompactionStaleRatioThreshold(column)));
+            } else if (fullTextColumns.contains(column)) {
+                definitions.add(
+                        new PrimaryKeyIndexDefinition(
+                                column,
+                                field.id(),
+                                "full-text",
+                                options.primaryKeyFullTextIndexOptions(column),
+                                PrimaryKeyIndexDefinition.Family.FULL_TEXT,
+                                
options.primaryKeyIndexCompactionLevelFanout(column),
+                                
options.primaryKeyIndexCompactionStaleRatioThreshold(column)));
             }
         }
 
@@ -102,11 +114,15 @@ public class PrimaryKeyIndexDefinitions {
     }
 
     private static void validateOneIndexPerColumn(
-            List<String> vectorColumns, List<String> btreeColumns, 
List<String> bitmapColumns) {
+            List<String> vectorColumns,
+            List<String> btreeColumns,
+            List<String> bitmapColumns,
+            List<String> fullTextColumns) {
         Set<String> indexedColumns = new HashSet<>();
         validateUniqueColumns(indexedColumns, vectorColumns);
         validateUniqueColumns(indexedColumns, btreeColumns);
         validateUniqueColumns(indexedColumns, bitmapColumns);
+        validateUniqueColumns(indexedColumns, fullTextColumns);
     }
 
     private static void validateUniqueColumns(Set<String> indexedColumns, 
List<String> columns) {
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/BucketedFullTextIndexMaintainer.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/BucketedFullTextIndexMaintainer.java
new file mode 100644
index 0000000000..9231c78979
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pkfulltext/BucketedFullTextIndexMaintainer.java
@@ -0,0 +1,557 @@
+/*
+ * 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.paimon.index.pkfulltext;
+
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.pk.PrimaryKeyIndexLevels;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourcePolicy;
+import org.apache.paimon.io.CompactIncrement;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataIncrement;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Maintains bucket-local full-text archives with source-backed LSM 
consolidation. */
+public class BucketedFullTextIndexMaintainer {
+
+    private static final int DEFAULT_LEVEL_FANOUT = 5;
+    private static final double DEFAULT_STALE_RATIO_THRESHOLD = 0.2;
+
+    private final int textFieldId;
+    private final PkFullTextIndexFile indexFile;
+    private final PkFullTextIndexBuilder indexBuilder;
+    private final PrimaryKeyIndexLevels<IndexFileMeta> levels;
+    private final List<IndexFileMeta> currentPayloads = new ArrayList<>();
+    private final List<IndexFileMeta> retiredPayloads = new ArrayList<>();
+    private final Map<String, DataFileMeta> activeSourceFiles = new 
LinkedHashMap<>();
+    private ExecutorService executor;
+    @Nullable private PendingBuild pendingBuild;
+
+    public BucketedFullTextIndexMaintainer(
+            int textFieldId,
+            PkFullTextIndexFile indexFile,
+            PkFullTextIndexBuilder indexBuilder,
+            List<DataFileMeta> restoredDataFiles,
+            List<IndexFileMeta> restoredPayloads,
+            ExecutorService executor) {
+        this(
+                textFieldId,
+                indexFile,
+                indexBuilder,
+                DEFAULT_LEVEL_FANOUT,
+                DEFAULT_STALE_RATIO_THRESHOLD,
+                restoredDataFiles,
+                restoredPayloads,
+                executor);
+    }
+
+    public BucketedFullTextIndexMaintainer(
+            int textFieldId,
+            PkFullTextIndexFile indexFile,
+            PkFullTextIndexBuilder indexBuilder,
+            int levelFanout,
+            double staleRatioThreshold,
+            List<DataFileMeta> restoredDataFiles,
+            List<IndexFileMeta> restoredPayloads,
+            ExecutorService executor) {
+        this.textFieldId = textFieldId;
+        this.indexFile = indexFile;
+        this.indexBuilder = indexBuilder;
+        this.levels =
+                new PrimaryKeyIndexLevels<>(
+                        levelFanout,
+                        staleRatioThreshold,
+                        IndexFileMeta::fileName,
+                        payload -> sourceMeta(payload).sourceFiles());
+        this.executor = executor;
+        for (DataFileMeta file : restoredDataFiles) {
+            if (PrimaryKeyIndexSourcePolicy.shouldRead(file)) {
+                activeSourceFiles.put(file.fileName(), file);
+            }
+        }
+
+        PkFullTextBucketIndexState restoredState =
+                PkFullTextBucketIndexState.fromActivePayloads(textFieldId, 
restoredPayloads);
+        currentPayloads.addAll(restoredState.currentPayloads());
+        retiredPayloads.addAll(restoredState.stalePayloads());
+        validateActiveSourceRows();
+    }
+
+    public synchronized FullTextIndexCommit prepareCommit(
+            DataIncrement appendIncrement,
+            CompactIncrement compactIncrement,
+            boolean waitCompaction)
+            throws Exception {
+        checkArgument(
+                eligibleFiles(appendIncrement.newFiles()).isEmpty(),
+                "Append files must not be primary-key full-text index 
sources.");
+
+        List<IndexFileMeta> originalPayloads = new 
ArrayList<>(currentPayloads);
+        List<IndexFileMeta> originalRetired = new ArrayList<>(retiredPayloads);
+        Map<String, DataFileMeta> originalSources = new 
LinkedHashMap<>(activeSourceFiles);
+        List<IndexFileMeta> generated = new ArrayList<>();
+        try {
+            applyDataTransition(compactIncrement);
+            List<IndexFileMeta> created = new ArrayList<>();
+            List<IndexFileMeta> removed = new ArrayList<>(retiredPayloads);
+            retiredPayloads.clear();
+
+            while (true) {
+                Optional<CompletedBuild> completed = 
finishPendingBuild(waitCompaction);
+                if (completed.isPresent()) {
+                    CompletedBuild build = completed.get();
+                    generated.add(build.payload);
+                    if (canAccept(build)) {
+                        replacePayloads(
+                                build.inputPayloads, build.payload, created, 
removed, generated);
+                    } else {
+                        deleteGenerated(build.payload, generated);
+                    }
+                }
+
+                if (pendingBuild == null) {
+                    List<DataFileMeta> uncovered = uncoveredFiles();
+                    if (!uncovered.isEmpty()) {
+                        startBuild(uncovered, Collections.emptyList());
+                    } else {
+                        Optional<PrimaryKeyIndexLevels.Plan<IndexFileMeta>> 
plan =
+                                levels.pick(currentPayloads, 
activeSourceFiles);
+                        if (plan.isPresent()) {
+                            if (plan.get().sourceFiles().isEmpty()) {
+                                removePayloads(
+                                        plan.get().inputUnits(), created, 
removed, generated);
+                                continue;
+                            }
+                            startBuild(plan.get().sourceFiles(), 
plan.get().inputUnits());
+                        }
+                    }
+                }
+                if (!waitCompaction || pendingBuild == null) {
+                    break;
+                }
+            }
+
+            boolean hasCompactDataTransition =
+                    !compactIncrement.compactBefore().isEmpty()
+                            || !compactIncrement.compactAfter().isEmpty();
+            boolean changed = !created.isEmpty() || !removed.isEmpty();
+            Optional<FullTextIndexIncrement> appendChange =
+                    changed && !hasCompactDataTransition
+                            ? Optional.of(new FullTextIndexIncrement(created, 
removed))
+                            : Optional.empty();
+            Optional<FullTextIndexIncrement> compactChange =
+                    changed && hasCompactDataTransition
+                            ? Optional.of(new FullTextIndexIncrement(created, 
removed))
+                            : Optional.empty();
+            return new FullTextIndexCommit(
+                    appendChange,
+                    compactChange,
+                    failure ->
+                            rollback(
+                                    originalPayloads,
+                                    originalRetired,
+                                    originalSources,
+                                    generated,
+                                    failure));
+        } catch (Throwable failure) {
+            rollback(originalPayloads, originalRetired, originalSources, 
generated, failure);
+            if (failure instanceof Exception) {
+                throw (Exception) failure;
+            }
+            if (failure instanceof Error) {
+                throw (Error) failure;
+            }
+            throw new RuntimeException(failure);
+        }
+    }
+
+    private void applyDataTransition(CompactIncrement compactIncrement) {
+        for (DataFileMeta file : compactIncrement.compactBefore()) {
+            if (!containsFile(compactIncrement.compactAfter(), 
file.fileName())) {
+                activeSourceFiles.remove(file.fileName());
+            }
+        }
+        for (DataFileMeta file : compactIncrement.compactAfter()) {
+            if (PrimaryKeyIndexSourcePolicy.shouldRead(file)) {
+                activeSourceFiles.put(file.fileName(), file);
+            }
+        }
+    }
+
+    private List<DataFileMeta> uncoveredFiles() {
+        Set<String> covered = coveredSources(currentPayloads);
+        List<DataFileMeta> uncovered = new ArrayList<>();
+        for (DataFileMeta source : activeSourceFiles.values()) {
+            if (!covered.contains(source.fileName())) {
+                uncovered.add(source);
+            }
+        }
+        uncovered.sort(Comparator.comparing(DataFileMeta::fileName));
+        return uncovered;
+    }
+
+    private void startBuild(List<DataFileMeta> sourceFiles, 
List<IndexFileMeta> inputPayloads) {
+        PendingBuild build = new PendingBuild(sourceFiles, inputPayloads);
+        build.start();
+        pendingBuild = build;
+    }
+
+    private Optional<CompletedBuild> finishPendingBuild(boolean blocking) 
throws Exception {
+        if (pendingBuild == null || (!blocking && !pendingBuild.isDone())) {
+            return Optional.empty();
+        }
+        PendingBuild completed = pendingBuild;
+        try {
+            IndexFileMeta payload = completed.get();
+            pendingBuild = null;
+            return Optional.of(
+                    new CompletedBuild(completed.sourceFiles, 
completed.inputPayloads, payload));
+        } catch (CancellationException e) {
+            pendingBuild = null;
+            return Optional.empty();
+        } catch (ExecutionException e) {
+            pendingBuild = null;
+            Throwable cause = e.getCause();
+            if (cause instanceof Exception) {
+                throw (Exception) cause;
+            }
+            if (cause instanceof Error) {
+                throw (Error) cause;
+            }
+            throw new RuntimeException(cause);
+        }
+    }
+
+    private boolean canAccept(CompletedBuild build) {
+        if (!currentPayloads.containsAll(build.inputPayloads)) {
+            return false;
+        }
+        PkFullTextBucketIndexState outputState =
+                PkFullTextBucketIndexState.fromActivePayloads(
+                        textFieldId, Collections.singletonList(build.payload));
+        if (outputState.currentPayloads().size() != 1) {
+            return false;
+        }
+        List<PrimaryKeyIndexSourceFile> actualSources = 
sourceMeta(build.payload).sourceFiles();
+        if (actualSources.size() != build.sourceFiles.size()) {
+            return false;
+        }
+
+        List<IndexFileMeta> retained = new ArrayList<>(currentPayloads);
+        retained.removeAll(build.inputPayloads);
+        Set<String> retainedSources = coveredSources(retained);
+        for (int i = 0; i < actualSources.size(); i++) {
+            PrimaryKeyIndexSourceFile actual = actualSources.get(i);
+            DataFileMeta expected = build.sourceFiles.get(i);
+            DataFileMeta active = activeSourceFiles.get(actual.fileName());
+            if (!actual.fileName().equals(expected.fileName())
+                    || actual.rowCount() != expected.rowCount()
+                    || active == null
+                    || active.rowCount() != actual.rowCount()
+                    || retainedSources.contains(actual.fileName())) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private void replacePayloads(
+            List<IndexFileMeta> inputs,
+            IndexFileMeta output,
+            List<IndexFileMeta> created,
+            List<IndexFileMeta> removed,
+            List<IndexFileMeta> generated) {
+        removePayloads(inputs, created, removed, generated);
+        currentPayloads.add(output);
+        created.add(output);
+    }
+
+    private void removePayloads(
+            List<IndexFileMeta> inputs,
+            List<IndexFileMeta> created,
+            List<IndexFileMeta> removed,
+            List<IndexFileMeta> generated) {
+        for (IndexFileMeta input : inputs) {
+            checkArgument(
+                    currentPayloads.remove(input), "Full-text rebuild input is 
no longer active.");
+            if (created.remove(input)) {
+                deleteGenerated(input, generated);
+            } else {
+                removed.add(input);
+            }
+        }
+    }
+
+    private void deleteGenerated(IndexFileMeta payload, List<IndexFileMeta> 
generated) {
+        indexFile.delete(payload);
+        generated.remove(payload);
+    }
+
+    private synchronized void rollback(
+            List<IndexFileMeta> originalPayloads,
+            List<IndexFileMeta> originalRetired,
+            Map<String, DataFileMeta> originalSources,
+            List<IndexFileMeta> generated,
+            Throwable failure) {
+        currentPayloads.clear();
+        currentPayloads.addAll(originalPayloads);
+        retiredPayloads.clear();
+        retiredPayloads.addAll(originalRetired);
+        activeSourceFiles.clear();
+        activeSourceFiles.putAll(originalSources);
+
+        PendingBuild build = pendingBuild;
+        pendingBuild = null;
+        if (build != null) {
+            try {
+                build.cancel();
+            } catch (Throwable cleanupFailure) {
+                failure.addSuppressed(cleanupFailure);
+            }
+        }
+        for (IndexFileMeta payload : generated) {
+            try {
+                indexFile.delete(payload);
+            } catch (Throwable cleanupFailure) {
+                failure.addSuppressed(cleanupFailure);
+            }
+        }
+    }
+
+    public synchronized boolean buildNotCompleted() {
+        return pendingBuild != null;
+    }
+
+    public synchronized void withExecutor(ExecutorService executor) {
+        checkArgument(pendingBuild == null, "Cannot replace executor during a 
full-text build.");
+        this.executor = executor;
+    }
+
+    public synchronized void close() {
+        PendingBuild build = pendingBuild;
+        pendingBuild = null;
+        if (build != null) {
+            build.cancel();
+        }
+    }
+
+    public synchronized PkFullTextBucketIndexState state() {
+        return PkFullTextBucketIndexState.fromActivePayloads(textFieldId, 
currentPayloads);
+    }
+
+    public synchronized List<IndexFileMeta> payloads() {
+        return Collections.unmodifiableList(new ArrayList<>(currentPayloads));
+    }
+
+    private void validateActiveSourceRows() {
+        for (IndexFileMeta payload : currentPayloads) {
+            for (PrimaryKeyIndexSourceFile source : 
sourceMeta(payload).sourceFiles()) {
+                DataFileMeta active = activeSourceFiles.get(source.fileName());
+                if (active != null) {
+                    checkArgument(
+                            active.rowCount() == source.rowCount(),
+                            "Full-text source %s row count does not match its 
active data file.",
+                            source.fileName());
+                }
+            }
+        }
+    }
+
+    private class PendingBuild {
+
+        private final List<DataFileMeta> sourceFiles;
+        private final List<IndexFileMeta> inputPayloads;
+        @Nullable private IndexFileMeta result;
+        @Nullable private Future<IndexFileMeta> future;
+        private boolean cancelled;
+
+        private PendingBuild(List<DataFileMeta> sourceFiles, 
List<IndexFileMeta> inputPayloads) {
+            this.sourceFiles = Collections.unmodifiableList(new 
ArrayList<>(sourceFiles));
+            this.inputPayloads = Collections.unmodifiableList(new 
ArrayList<>(inputPayloads));
+        }
+
+        private void start() {
+            future =
+                    executor.submit(
+                            () -> {
+                                IndexFileMeta payload =
+                                        sourceFiles.size() == 1
+                                                ? 
indexBuilder.build(sourceFiles.get(0))
+                                                : 
indexBuilder.build(sourceFiles);
+                                synchronized (PendingBuild.this) {
+                                    if (!cancelled) {
+                                        result = payload;
+                                        return payload;
+                                    }
+                                }
+                                indexFile.delete(payload);
+                                throw new CancellationException();
+                            });
+        }
+
+        private boolean isDone() {
+            return future.isDone();
+        }
+
+        private IndexFileMeta get() throws InterruptedException, 
ExecutionException {
+            return future.get();
+        }
+
+        private void cancel() {
+            Future<IndexFileMeta> buildFuture;
+            IndexFileMeta builtPayload;
+            synchronized (this) {
+                cancelled = true;
+                buildFuture = future;
+                builtPayload = result;
+                result = null;
+            }
+            if (buildFuture != null) {
+                buildFuture.cancel(true);
+            }
+            if (builtPayload != null) {
+                indexFile.delete(builtPayload);
+            }
+        }
+    }
+
+    private static final class CompletedBuild {
+
+        private final List<DataFileMeta> sourceFiles;
+        private final List<IndexFileMeta> inputPayloads;
+        private final IndexFileMeta payload;
+
+        private CompletedBuild(
+                List<DataFileMeta> sourceFiles,
+                List<IndexFileMeta> inputPayloads,
+                IndexFileMeta payload) {
+            this.sourceFiles = sourceFiles;
+            this.inputPayloads = inputPayloads;
+            this.payload = payload;
+        }
+    }
+
+    private static Set<String> coveredSources(List<IndexFileMeta> payloads) {
+        Set<String> covered = new HashSet<>();
+        for (IndexFileMeta payload : payloads) {
+            for (PrimaryKeyIndexSourceFile source : 
sourceMeta(payload).sourceFiles()) {
+                covered.add(source.fileName());
+            }
+        }
+        return covered;
+    }
+
+    private static List<DataFileMeta> eligibleFiles(List<DataFileMeta> files) {
+        List<DataFileMeta> eligible = new ArrayList<>();
+        for (DataFileMeta file : files) {
+            if (PrimaryKeyIndexSourcePolicy.shouldRead(file)) {
+                eligible.add(file);
+            }
+        }
+        return eligible;
+    }
+
+    private static boolean containsFile(List<DataFileMeta> files, String 
fileName) {
+        for (DataFileMeta file : files) {
+            if (file.fileName().equals(fileName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static PrimaryKeyIndexSourceMeta sourceMeta(IndexFileMeta payload) 
{
+        return PrimaryKeyIndexSourceMeta.fromIndexFile(payload);
+    }
+
+    /** Full-text changes prepared for Paimon's append snapshot or compact 
snapshot. */
+    public static class FullTextIndexCommit {
+
+        private final Optional<FullTextIndexIncrement> appendIncrement;
+        private final Optional<FullTextIndexIncrement> compactIncrement;
+        private final AbortAction abortAction;
+
+        private FullTextIndexCommit(
+                Optional<FullTextIndexIncrement> appendIncrement,
+                Optional<FullTextIndexIncrement> compactIncrement,
+                AbortAction abortAction) {
+            this.appendIncrement = appendIncrement;
+            this.compactIncrement = compactIncrement;
+            this.abortAction = abortAction;
+        }
+
+        public Optional<FullTextIndexIncrement> appendIncrement() {
+            return appendIncrement;
+        }
+
+        public Optional<FullTextIndexIncrement> compactIncrement() {
+            return compactIncrement;
+        }
+
+        public void abort(Throwable failure) {
+            abortAction.abort(failure);
+        }
+    }
+
+    @FunctionalInterface
+    private interface AbortAction {
+
+        void abort(Throwable failure);
+    }
+
+    /** Index-file additions and deletions emitted by one bucket state update. 
*/
+    public static class FullTextIndexIncrement {
+
+        private final List<IndexFileMeta> newIndexFiles;
+        private final List<IndexFileMeta> deletedIndexFiles;
+
+        private FullTextIndexIncrement(
+                List<IndexFileMeta> newIndexFiles, List<IndexFileMeta> 
deletedIndexFiles) {
+            this.newIndexFiles = Collections.unmodifiableList(new 
ArrayList<>(newIndexFiles));
+            this.deletedIndexFiles =
+                    Collections.unmodifiableList(new 
ArrayList<>(deletedIndexFiles));
+        }
+
+        public List<IndexFileMeta> newIndexFiles() {
+            return newIndexFiles;
+        }
+
+        public List<IndexFileMeta> deletedIndexFiles() {
+            return deletedIndexFiles;
+        }
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java 
b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java
index b8e27fb956..c9c1a6b5f5 100644
--- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java
+++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaManager.java
@@ -997,7 +997,8 @@ public class SchemaManager implements Serializable {
         CoreOptions options = new CoreOptions(schema.options());
         if (options.primaryKeyVectorIndexColumns().contains(fieldName)
                 || options.primaryKeyBTreeIndexColumns().contains(fieldName)
-                || options.primaryKeyBitmapIndexColumns().contains(fieldName)) 
{
+                || options.primaryKeyBitmapIndexColumns().contains(fieldName)
+                || 
options.primaryKeyFullTextIndexColumns().contains(fieldName)) {
             throw new UnsupportedOperationException(
                     String.format(
                             "Cannot %s primary-key index column: [%s]", 
operation, fieldName));
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java 
b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
index fb0eb2e6f2..9f74310736 100644
--- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
+++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
@@ -358,6 +358,7 @@ public class SchemaValidation {
         validatePrimaryKeyIndexColumns(options);
         validatePrimaryKeySortedIndexes(schema, options);
         validatePrimaryKeyVectorIndex(schema, options);
+        validatePrimaryKeyFullTextIndex(schema, options);
 
         validateMergeFunctionFactory(schema);
 
@@ -957,22 +958,79 @@ public class SchemaValidation {
                 options.primaryKeyVectorDistanceMetric(indexColumn));
     }
 
+    private static void validatePrimaryKeyFullTextIndex(TableSchema schema, 
CoreOptions options) {
+        if (!options.primaryKeyFullTextIndexEnabled()) {
+            return;
+        }
+
+        List<String> indexColumns = options.primaryKeyFullTextIndexColumns();
+        checkArgument(
+                indexColumns.size() == 1,
+                "%s must contain exactly one column in the first release, but 
is %s.",
+                CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key(),
+                indexColumns);
+        String indexColumn = indexColumns.get(0);
+        checkArgument(
+                !StringUtils.isNullOrWhitespaceOnly(indexColumn),
+                "%s must contain a non-empty column.",
+                CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key());
+        checkArgument(
+                !schema.primaryKeys().isEmpty(),
+                "Primary-key full-text index requires a primary-key table.");
+        checkArgument(
+                options.mergeEngine() == MergeEngine.FIRST_ROW || 
options.deletionVectorsEnabled(),
+                "Primary-key full-text index requires deletion-vectors.enabled 
= true.");
+        checkArgument(
+                !options.deletionVectorsMergeOnRead(),
+                "Primary-key full-text index requires 
deletion-vectors.merge-on-read = false.");
+        checkArgument(
+                options.bucket() > 0 || options.bucket() == 
BucketMode.POSTPONE_BUCKET,
+                "Primary-key full-text index requires fixed or postpone bucket 
mode "
+                        + "(bucket > 0 or bucket = -2), but bucket is %s.",
+                options.bucket());
+        checkArgument(
+                !options.pkClusteringOverride(),
+                "Primary-key full-text index does not support 
pk-clustering-override.");
+        checkArgument(
+                schema.nameToFieldMap().containsKey(indexColumn),
+                "%s entry '%s' must reference an existing column.",
+                CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key(),
+                indexColumn);
+        DataTypeRoot typeRoot = 
schema.nameToFieldMap().get(indexColumn).type().getTypeRoot();
+        checkArgument(
+                typeRoot == DataTypeRoot.CHAR || typeRoot == 
DataTypeRoot.VARCHAR,
+                "%s entry '%s' must reference a CHAR/VARCHAR/STRING column.",
+                CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key(),
+                indexColumn);
+        options.primaryKeyFullTextIndexOptions(indexColumn);
+    }
+
     private static void validatePrimaryKeyIndexColumns(CoreOptions options) {
         List<String> vectorColumns = options.primaryKeyVectorIndexColumns();
         List<String> btreeColumns = options.primaryKeyBTreeIndexColumns();
         List<String> bitmapColumns = options.primaryKeyBitmapIndexColumns();
+        List<String> fullTextColumns = 
options.primaryKeyFullTextIndexColumns();
         validateNoDuplicatePrimaryKeyIndexColumns(
                 vectorColumns, CoreOptions.PK_VECTOR_INDEX_COLUMNS.key());
         validateNoDuplicatePrimaryKeyIndexColumns(
                 btreeColumns, CoreOptions.PK_BTREE_INDEX_COLUMNS.key());
         validateNoDuplicatePrimaryKeyIndexColumns(
                 bitmapColumns, CoreOptions.PK_BITMAP_INDEX_COLUMNS.key());
+        validateNoDuplicatePrimaryKeyIndexColumns(
+                fullTextColumns, CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key());
 
         Set<String> indexedColumns = new HashSet<>();
         validateUniquePrimaryKeyIndexColumns(indexedColumns, vectorColumns);
         validateUniquePrimaryKeyIndexColumns(indexedColumns, btreeColumns);
         validateUniquePrimaryKeyIndexColumns(indexedColumns, bitmapColumns);
-        for (String column : indexedColumns) {
+        validateUniquePrimaryKeyIndexColumns(indexedColumns, fullTextColumns);
+
+        Set<String> compactedIndexColumns = new HashSet<>();
+        compactedIndexColumns.addAll(vectorColumns);
+        compactedIndexColumns.addAll(btreeColumns);
+        compactedIndexColumns.addAll(bitmapColumns);
+        compactedIndexColumns.addAll(fullTextColumns);
+        for (String column : compactedIndexColumns) {
             String fanoutKey = 
CoreOptions.primaryKeyIndexCompactionLevelFanoutKey(column);
             checkArgument(
                     options.primaryKeyIndexCompactionLevelFanout(column) > 1,
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/index/pk/BucketedPrimaryKeyIndexMaintainerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/index/pk/BucketedPrimaryKeyIndexMaintainerTest.java
index b5d7e8d6ea..cb0955237a 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/index/pk/BucketedPrimaryKeyIndexMaintainerTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/index/pk/BucketedPrimaryKeyIndexMaintainerTest.java
@@ -18,19 +18,28 @@
 
 package org.apache.paimon.index.pk;
 
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.fs.local.LocalFileIO;
 import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileHandler;
 import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.index.pkfulltext.BucketedFullTextIndexMaintainer;
+import org.apache.paimon.index.pkfulltext.PkFullTextIndexFile;
 import org.apache.paimon.index.pksorted.BucketedSortedIndexMaintainer;
 import org.apache.paimon.index.pksorted.PkSortedIndexFile;
 import org.apache.paimon.index.pkvector.BucketedVectorIndexMaintainer;
 import org.apache.paimon.io.CompactIncrement;
 import org.apache.paimon.io.DataFileMeta;
 import org.apache.paimon.io.DataIncrement;
+import org.apache.paimon.io.KeyValueFileReaderFactory;
 import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.schema.TableSchema;
 import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
 
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
@@ -39,7 +48,9 @@ import org.junit.jupiter.api.io.TempDir;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.UUID;
 import java.util.concurrent.CountDownLatch;
@@ -51,6 +62,7 @@ import java.util.concurrent.atomic.AtomicReference;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Answers.RETURNS_SELF;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
@@ -84,6 +96,104 @@ class BucketedPrimaryKeyIndexMaintainerTest {
         verify(vector).close();
     }
 
+    @Test
+    void testDelegatesFullTextLifecycleAndMergesCommit() throws Exception {
+        IndexFileMeta payload =
+                new IndexFileMeta("full-text", "payload", 1, 1, 
(GlobalIndexMeta) null, null);
+        BucketedFullTextIndexMaintainer fullText = 
mock(BucketedFullTextIndexMaintainer.class);
+        BucketedFullTextIndexMaintainer.FullTextIndexCommit commit =
+                
mock(BucketedFullTextIndexMaintainer.FullTextIndexCommit.class);
+        BucketedFullTextIndexMaintainer.FullTextIndexIncrement increment =
+                
mock(BucketedFullTextIndexMaintainer.FullTextIndexIncrement.class);
+        when(fullText.prepareCommit(any(), any(), 
eq(true))).thenReturn(commit);
+        when(commit.appendIncrement()).thenReturn(Optional.empty());
+        when(commit.compactIncrement()).thenReturn(Optional.of(increment));
+        
when(increment.newIndexFiles()).thenReturn(Collections.singletonList(payload));
+        
when(increment.deletedIndexFiles()).thenReturn(Collections.emptyList());
+        when(fullText.buildNotCompleted()).thenReturn(true);
+        BucketedPrimaryKeyIndexMaintainer maintainer =
+                BucketedPrimaryKeyIndexMaintainer.ofFullText(fullText);
+        CompactIncrement compactIncrement = CompactIncrement.emptyIncrement();
+
+        maintainer.prepareCommit(DataIncrement.emptyIncrement(), 
compactIncrement, true);
+
+        assertThat(compactIncrement.newIndexFiles()).containsExactly(payload);
+        assertThat(maintainer.buildNotCompleted()).isTrue();
+        maintainer.withExecutor(buildExecutor);
+        maintainer.close();
+        verify(fullText).withExecutor(buildExecutor);
+        verify(fullText).close();
+    }
+
+    @Test
+    void testFactoryCreatesConfiguredFullTextMaintainer() {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key(), "content");
+        options.put("fields.content.pk-index.compaction.level-fanout", "2");
+        
options.put("fields.content.pk-index.compaction.stale-ratio-threshold", "1.0");
+        TableSchema schema =
+                new TableSchema(
+                        0,
+                        Arrays.asList(
+                                new DataField(0, "id", 
DataTypes.INT().notNull()),
+                                new DataField(1, "content", 
DataTypes.STRING())),
+                        0,
+                        Collections.emptyList(),
+                        Collections.singletonList("id"),
+                        options,
+                        "");
+        IndexFileHandler handler = mock(IndexFileHandler.class);
+        when(handler.pkFullTextIndex(any(), 
eq(0))).thenReturn(mock(PkFullTextIndexFile.class));
+        KeyValueFileReaderFactory.Builder readerBuilder =
+                mock(KeyValueFileReaderFactory.Builder.class, RETURNS_SELF);
+        DataFileMeta first = dataFile("data-1", 1);
+        DataFileMeta second = dataFile("data-2", 1);
+        BucketedPrimaryKeyIndexMaintainer.Factory factory =
+                BucketedPrimaryKeyIndexMaintainer.Factory.create(handler, 
readerBuilder, schema);
+        BucketedPrimaryKeyIndexMaintainer maintainer =
+                factory.create(
+                        BinaryRow.EMPTY_ROW,
+                        0,
+                        Arrays.asList(first, second),
+                        Arrays.asList(
+                                fullTextPayload("payload-1", first),
+                                fullTextPayload("payload-2", second)),
+                        buildExecutor);
+
+        assertThat(factory.indexFileHandler()).isSameAs(handler);
+        assertThat(maintainer.buildNotCompleted()).isFalse();
+    }
+
+    @Test
+    void testLaterDefinitionFailureAbortsPreparedFullTextCommit() throws 
Exception {
+        DataFileMeta source = dataFile("data-1", 3);
+        BucketedFullTextIndexMaintainer fullText = 
mock(BucketedFullTextIndexMaintainer.class);
+        BucketedFullTextIndexMaintainer.FullTextIndexCommit fullTextCommit =
+                
mock(BucketedFullTextIndexMaintainer.FullTextIndexCommit.class);
+        when(fullText.prepareCommit(any(), any(), 
eq(true))).thenReturn(fullTextCommit);
+        when(fullTextCommit.appendIncrement()).thenReturn(Optional.empty());
+        when(fullTextCommit.compactIncrement()).thenReturn(Optional.empty());
+        BucketedSortedIndexMaintainer failing =
+                sortedMaintainer(
+                        8,
+                        "bitmap",
+                        source,
+                        dataFile -> {
+                            throw new IllegalStateException("expected sorted 
failure");
+                        });
+        BucketedPrimaryKeyIndexMaintainer maintainer =
+                BucketedPrimaryKeyIndexMaintainer.of(
+                        null, fullText, Collections.singletonList(failing));
+
+        assertThatThrownBy(
+                        () ->
+                                maintainer.prepareCommit(
+                                        DataIncrement.emptyIncrement(), 
compactAfter(source), true))
+                .hasMessage("expected sorted failure");
+
+        verify(fullTextCommit).abort(any());
+    }
+
     @Test
     void testScalarFailureAbortsOtherDefinitionsAndVector() throws Exception {
         DataFileMeta source = dataFile("data-1", 3);
@@ -369,6 +479,26 @@ class BucketedPrimaryKeyIndexMaintainerTest {
                 null);
     }
 
+    private static IndexFileMeta fullTextPayload(String fileName, DataFileMeta 
sourceFile) {
+        PrimaryKeyIndexSourceMeta sourceMeta =
+                new PrimaryKeyIndexSourceMeta(
+                        new PrimaryKeyIndexSourceFile(
+                                sourceFile.fileName(), sourceFile.rowCount()));
+        return new IndexFileMeta(
+                "full-text",
+                fileName,
+                1,
+                sourceFile.rowCount(),
+                new GlobalIndexMeta(
+                        0,
+                        sourceFile.rowCount() - 1,
+                        1,
+                        null,
+                        new byte[] {1},
+                        sourceMeta.serialize()),
+                null);
+    }
+
     private static DataFileMeta dataFile(String fileName, long rowCount) {
         return DataFileMeta.forAppend(
                         fileName,
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinitionsTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinitionsTest.java
index ab8ca6d2bf..378c1ad274 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinitionsTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/index/pk/PrimaryKeyIndexDefinitionsTest.java
@@ -85,6 +85,38 @@ class PrimaryKeyIndexDefinitionsTest {
         assertThat(definition.compactionStaleRatioThreshold()).isEqualTo(0.4);
     }
 
+    @Test
+    void testCreatesFullTextDefinitionWithoutIndexType() {
+        Map<String, String> options = new HashMap<>();
+        options.put("pk-full-text.index.columns", "name");
+
+        List<PrimaryKeyIndexDefinition> definitions =
+                
PrimaryKeyIndexDefinitions.create(schema(options)).definitions();
+
+        assertThat(definitions).hasSize(1);
+        assertThat(definitions.get(0).column()).isEqualTo("name");
+        assertThat(definitions.get(0).indexType()).isEqualTo("full-text");
+        assertThat(definitions.get(0).family().name()).isEqualTo("FULL_TEXT");
+    }
+
+    @Test
+    void testResolvesFullTextIndexOptions() {
+        Map<String, String> options = new HashMap<>();
+        options.put("pk-full-text.index.columns", "name");
+        options.put(
+                "fields.name.pk-full-text.index.options",
+                
"{\"full-text.tokenizer\":\"jieba\",\"ngram.min-gram\":\"2\"}");
+
+        PrimaryKeyIndexDefinition definition =
+                
PrimaryKeyIndexDefinitions.create(schema(options)).definitions().get(0);
+
+        
assertThat(definition.options().get("full-text.tokenizer")).isEqualTo("jieba");
+        
assertThat(definition.options().get("full-text.ngram.min-gram")).isEqualTo("2");
+        assertThat(definition.options().toMap())
+                .doesNotContainKey("pk-full-text.index.columns")
+                .doesNotContainKey("fields.name.pk-full-text.index.options");
+    }
+
     @Test
     void testLegacyVectorCompactionOptionsAreIgnored() {
         Map<String, String> options = new HashMap<>();
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/index/pkfulltext/BucketedFullTextIndexMaintainerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/index/pkfulltext/BucketedFullTextIndexMaintainerTest.java
new file mode 100644
index 0000000000..0036da2d87
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/index/pkfulltext/BucketedFullTextIndexMaintainerTest.java
@@ -0,0 +1,343 @@
+/*
+ * 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.paimon.index.pkfulltext;
+
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
+import org.apache.paimon.io.CompactIncrement;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataIncrement;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.stats.SimpleStats;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** Tests file-aligned maintenance of primary-key full-text archives. */
+class BucketedFullTextIndexMaintainerTest {
+
+    private final ExecutorService executor = 
Executors.newSingleThreadExecutor();
+
+    @AfterEach
+    void shutdownExecutor() {
+        executor.shutdownNow();
+    }
+
+    @Test
+    void testReplacesArchiveWithCompactSourceWithoutMergingArchives() throws 
Exception {
+        DataFileMeta oldData = dataFile("old-data");
+        DataFileMeta newData = dataFile("new-data");
+        IndexFileMeta oldPayload = payload("old-payload", oldData);
+        IndexFileMeta newPayload = payload("new-payload", newData);
+        PkFullTextIndexBuilder builder = mock(PkFullTextIndexBuilder.class);
+        when(builder.build(newData)).thenReturn(newPayload);
+        BucketedFullTextIndexMaintainer maintainer =
+                new BucketedFullTextIndexMaintainer(
+                        7,
+                        mock(PkFullTextIndexFile.class),
+                        builder,
+                        Collections.singletonList(oldData),
+                        Collections.singletonList(oldPayload),
+                        executor);
+
+        BucketedFullTextIndexMaintainer.FullTextIndexCommit commit =
+                maintainer.prepareCommit(
+                        DataIncrement.emptyIncrement(),
+                        new CompactIncrement(
+                                Collections.singletonList(oldData),
+                                Collections.singletonList(newData),
+                                Collections.emptyList()),
+                        true);
+
+        assertThat(commit.compactIncrement()).isPresent();
+        
assertThat(commit.compactIncrement().get().newIndexFiles()).containsExactly(newPayload);
+        
assertThat(commit.compactIncrement().get().deletedIndexFiles()).containsExactly(oldPayload);
+        
assertThat(maintainer.state().payloadBySourceFile()).containsOnlyKeys("new-data");
+        verify(builder).build(newData);
+    }
+
+    @Test
+    void testMergesArchivesAtConfiguredFanout() throws Exception {
+        DataFileMeta first = dataFile("data-1");
+        DataFileMeta second = dataFile("data-2");
+        IndexFileMeta firstPayload = payload("payload-1", first);
+        IndexFileMeta secondPayload = payload("payload-2", second);
+        IndexFileMeta merged = payload("merged", Arrays.asList(first, second));
+        PkFullTextIndexBuilder builder = mock(PkFullTextIndexBuilder.class);
+        when(builder.build(Arrays.asList(first, second))).thenReturn(merged);
+        BucketedFullTextIndexMaintainer maintainer =
+                new BucketedFullTextIndexMaintainer(
+                        7,
+                        mock(PkFullTextIndexFile.class),
+                        builder,
+                        2,
+                        0.5,
+                        Arrays.asList(first, second),
+                        Arrays.asList(firstPayload, secondPayload),
+                        executor);
+
+        BucketedFullTextIndexMaintainer.FullTextIndexCommit commit =
+                maintainer.prepareCommit(
+                        DataIncrement.emptyIncrement(), 
CompactIncrement.emptyIncrement(), true);
+
+        assertThat(commit.appendIncrement()).isPresent();
+        
assertThat(commit.appendIncrement().get().newIndexFiles()).containsExactly(merged);
+        assertThat(commit.appendIncrement().get().deletedIndexFiles())
+                .containsExactly(firstPayload, secondPayload);
+        
assertThat(maintainer.state().payloadBySourceFile()).containsOnlyKeys("data-1", 
"data-2");
+        assertThat(maintainer.payloads()).containsExactly(merged);
+        verify(builder).build(Arrays.asList(first, second));
+    }
+
+    @Test
+    void testRebuildsArchiveAtConfiguredStaleRatio() throws Exception {
+        DataFileMeta active = dataFile("active");
+        DataFileMeta removed = dataFile("removed");
+        IndexFileMeta oldPayload = payload("old-payload", 
Arrays.asList(active, removed));
+        IndexFileMeta rebuilt = payload("rebuilt", active);
+        PkFullTextIndexBuilder builder = mock(PkFullTextIndexBuilder.class);
+        when(builder.build(active)).thenReturn(rebuilt);
+        BucketedFullTextIndexMaintainer maintainer =
+                new BucketedFullTextIndexMaintainer(
+                        7,
+                        mock(PkFullTextIndexFile.class),
+                        builder,
+                        5,
+                        0.5,
+                        Collections.singletonList(active),
+                        Collections.singletonList(oldPayload),
+                        executor);
+
+        BucketedFullTextIndexMaintainer.FullTextIndexCommit commit =
+                maintainer.prepareCommit(
+                        DataIncrement.emptyIncrement(), 
CompactIncrement.emptyIncrement(), true);
+
+        assertThat(commit.appendIncrement()).isPresent();
+        
assertThat(commit.appendIncrement().get().newIndexFiles()).containsExactly(rebuilt);
+        
assertThat(commit.appendIncrement().get().deletedIndexFiles()).containsExactly(oldPayload);
+        assertThat(maintainer.payloads()).containsExactly(rebuilt);
+        verify(builder).build(active);
+    }
+
+    @Test
+    void testRetriesBuildAfterFailure() throws Exception {
+        DataFileMeta data = dataFile("data");
+        IndexFileMeta newPayload = payload("new-payload", data);
+        PkFullTextIndexBuilder builder = mock(PkFullTextIndexBuilder.class);
+        when(builder.build(data))
+                .thenThrow(new IOException("first failure"))
+                .thenReturn(newPayload);
+        BucketedFullTextIndexMaintainer maintainer =
+                new BucketedFullTextIndexMaintainer(
+                        7,
+                        mock(PkFullTextIndexFile.class),
+                        builder,
+                        Collections.singletonList(data),
+                        Collections.emptyList(),
+                        executor);
+
+        assertThatThrownBy(
+                        () ->
+                                maintainer.prepareCommit(
+                                        DataIncrement.emptyIncrement(),
+                                        CompactIncrement.emptyIncrement(),
+                                        true))
+                .hasMessageContaining("first failure");
+
+        BucketedFullTextIndexMaintainer.FullTextIndexCommit commit =
+                maintainer.prepareCommit(
+                        DataIncrement.emptyIncrement(), 
CompactIncrement.emptyIncrement(), true);
+
+        assertThat(commit.appendIncrement()).isPresent();
+        
assertThat(commit.appendIncrement().get().newIndexFiles()).containsExactly(newPayload);
+        
assertThat(commit.appendIncrement().get().deletedIndexFiles()).isEmpty();
+    }
+
+    @Test
+    void testNonBlockingBuildPublishesOnALaterAppendIncrement() throws 
Exception {
+        DataFileMeta data = dataFile("data");
+        IndexFileMeta payload = payload("payload", data);
+        CountDownLatch buildStarted = new CountDownLatch(1);
+        CountDownLatch allowBuild = new CountDownLatch(1);
+        PkFullTextIndexBuilder builder = mock(PkFullTextIndexBuilder.class);
+        when(builder.build(data))
+                .thenAnswer(
+                        ignored -> {
+                            buildStarted.countDown();
+                            assertThat(allowBuild.await(30, 
TimeUnit.SECONDS)).isTrue();
+                            return payload;
+                        });
+        BucketedFullTextIndexMaintainer maintainer =
+                new BucketedFullTextIndexMaintainer(
+                        7,
+                        mock(PkFullTextIndexFile.class),
+                        builder,
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        executor);
+        try {
+            BucketedFullTextIndexMaintainer.FullTextIndexCommit first =
+                    maintainer.prepareCommit(
+                            DataIncrement.emptyIncrement(),
+                            new CompactIncrement(
+                                    Collections.emptyList(),
+                                    Collections.singletonList(data),
+                                    Collections.emptyList()),
+                            false);
+            assertThat(first.compactIncrement()).isEmpty();
+            assertThat(buildStarted.await(30, TimeUnit.SECONDS)).isTrue();
+            assertThat(maintainer.buildNotCompleted()).isTrue();
+            allowBuild.countDown();
+
+            BucketedFullTextIndexMaintainer.FullTextIndexCommit second =
+                    maintainer.prepareCommit(
+                            DataIncrement.emptyIncrement(),
+                            CompactIncrement.emptyIncrement(),
+                            true);
+            assertThat(second.appendIncrement()).isPresent();
+            
assertThat(second.appendIncrement().get().newIndexFiles()).containsExactly(payload);
+        } finally {
+            allowBuild.countDown();
+        }
+    }
+
+    @Test
+    void testCoordinatorAbortRestoresStateAndDeletesGeneratedArchive() throws 
Exception {
+        DataFileMeta data = dataFile("data");
+        IndexFileMeta payload = payload("payload", data);
+        PkFullTextIndexFile indexFile = mock(PkFullTextIndexFile.class);
+        PkFullTextIndexBuilder builder = mock(PkFullTextIndexBuilder.class);
+        when(builder.build(data)).thenReturn(payload);
+        BucketedFullTextIndexMaintainer maintainer =
+                new BucketedFullTextIndexMaintainer(
+                        7,
+                        indexFile,
+                        builder,
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        executor);
+
+        BucketedFullTextIndexMaintainer.FullTextIndexCommit commit =
+                maintainer.prepareCommit(
+                        DataIncrement.emptyIncrement(),
+                        new CompactIncrement(
+                                Collections.emptyList(),
+                                Collections.singletonList(data),
+                                Collections.emptyList()),
+                        true);
+        assertThat(maintainer.payloads()).containsExactly(payload);
+
+        commit.abort(new IllegalStateException("later definition failed"));
+
+        assertThat(maintainer.payloads()).isEmpty();
+        verify(indexFile).delete(payload);
+    }
+
+    @Test
+    void testAbortDoesNotDeleteRejectedArchiveTwice() throws Exception {
+        DataFileMeta data = dataFile("data");
+        IndexFileMeta rejected = payload("rejected", dataFile("other"));
+        IndexFileMeta accepted = payload("accepted", data);
+        PkFullTextIndexFile indexFile = mock(PkFullTextIndexFile.class);
+        PkFullTextIndexBuilder builder = mock(PkFullTextIndexBuilder.class);
+        when(builder.build(data)).thenReturn(rejected, accepted);
+        BucketedFullTextIndexMaintainer maintainer =
+                new BucketedFullTextIndexMaintainer(
+                        7,
+                        indexFile,
+                        builder,
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        executor);
+
+        BucketedFullTextIndexMaintainer.FullTextIndexCommit commit =
+                maintainer.prepareCommit(
+                        DataIncrement.emptyIncrement(),
+                        new CompactIncrement(
+                                Collections.emptyList(),
+                                Collections.singletonList(data),
+                                Collections.emptyList()),
+                        true);
+
+        verify(indexFile).delete(rejected);
+        commit.abort(new IllegalStateException("later definition failed"));
+
+        verify(indexFile, times(1)).delete(rejected);
+        verify(indexFile).delete(accepted);
+    }
+
+    private static DataFileMeta dataFile(String fileName) {
+        return DataFileMeta.forAppend(
+                        fileName,
+                        100,
+                        1,
+                        SimpleStats.EMPTY_STATS,
+                        0,
+                        1,
+                        1,
+                        Collections.emptyList(),
+                        null,
+                        FileSource.COMPACT,
+                        null,
+                        null,
+                        null,
+                        null)
+                .upgrade(1);
+    }
+
+    private static IndexFileMeta payload(String payloadName, DataFileMeta 
source) {
+        return payload(payloadName, Collections.singletonList(source));
+    }
+
+    private static IndexFileMeta payload(String payloadName, 
List<DataFileMeta> sources) {
+        List<PrimaryKeyIndexSourceFile> sourceFiles = new ArrayList<>();
+        long rowCount = 0;
+        for (DataFileMeta source : sources) {
+            sourceFiles.add(new PrimaryKeyIndexSourceFile(source.fileName(), 
source.rowCount()));
+            rowCount += source.rowCount();
+        }
+        PrimaryKeyIndexSourceMeta sourceMeta = new 
PrimaryKeyIndexSourceMeta(sourceFiles);
+        return new IndexFileMeta(
+                "full-text",
+                payloadName,
+                100,
+                rowCount,
+                new GlobalIndexMeta(0, rowCount - 1, 7, null, null, 
sourceMeta.serialize()),
+                null);
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/PrimaryKeyIndexWriteTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/PrimaryKeyIndexWriteTest.java
index afd56c6207..ed16b21e01 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/PrimaryKeyIndexWriteTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/PrimaryKeyIndexWriteTest.java
@@ -128,6 +128,29 @@ class PrimaryKeyIndexWriteTest {
         write.close();
     }
 
+    @Test
+    void testCreatesCoordinatorForFullTextDefinition() throws Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.BUCKET.key(), "10");
+        options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+        options.put(CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key(), "comment");
+        TestFileStore store = createStore(options);
+        KeyValueFileStoreWrite write = (KeyValueFileStoreWrite) 
store.newWrite();
+        write.withIOManager(ioManager);
+        TestKeyValueGenerator generator = new TestKeyValueGenerator();
+        KeyValue record = generator.next();
+
+        AbstractFileStoreWrite.WriterContainer<KeyValue> container =
+                write.createWriterContainer(generator.getPartition(record), 1);
+
+        assertThat(container.primaryKeyIndexMaintainer).isNotNull();
+        assertThat(readField(container.primaryKeyIndexMaintainer, 
"fullTextMaintainer"))
+                .isNotNull();
+        assertThat((List<?>) readField(container.primaryKeyIndexMaintainer, 
"sortedMaintainers"))
+                .isEmpty();
+        write.close();
+    }
+
     @Test
     void testPostponeBucketDefersCoordinatorUntilFixedBucketCompaction() 
throws Exception {
         Map<String, String> options = new HashMap<>();
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeyFullTextIndexValidationTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeyFullTextIndexValidationTest.java
new file mode 100644
index 0000000000..f8a59512ac
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeyFullTextIndexValidationTest.java
@@ -0,0 +1,243 @@
+/*
+ * 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.paimon.schema;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.apache.paimon.schema.SchemaValidation.validateTableSchema;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for primary-key full-text index option validation. */
+class PrimaryKeyFullTextIndexValidationTest {
+
+    @Test
+    void testValidConfiguration() {
+        assertThatCode(() -> validateTableSchema(schema(enabledOptions())))
+                .doesNotThrowAnyException();
+    }
+
+    @Test
+    void testRejectsMultipleColumnsForFirstRelease() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key(), 
"content,other_content");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                
.hasMessageContaining(CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key())
+                .hasMessageContaining("exactly one column");
+    }
+
+    @Test
+    void testRejectsDuplicateColumns() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key(), 
"content,content");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                
.hasMessageContaining(CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key())
+                .hasMessageContaining("duplicate");
+    }
+
+    @Test
+    void testRejectsEmptyColumn() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key(), " ");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                
.hasMessageContaining(CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key())
+                .hasMessageContaining("non-empty column");
+    }
+
+    @Test
+    void testRejectsUnknownColumn() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key(), "unknown");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining("entry 'unknown'")
+                .hasMessageContaining("existing column");
+    }
+
+    @Test
+    void testRejectsNonCharacterColumn() {
+        TableSchema intContentSchema =
+                new TableSchema(
+                        0,
+                        Arrays.asList(
+                                new DataField(0, "id", 
DataTypes.INT().notNull()),
+                                new DataField(1, "content", DataTypes.INT())),
+                        0,
+                        Collections.emptyList(),
+                        Collections.singletonList("id"),
+                        enabledOptions(),
+                        "");
+
+        assertThatThrownBy(() -> validateTableSchema(intContentSchema))
+                .hasMessageContaining("entry 'content'")
+                .hasMessageContaining("CHAR/VARCHAR/STRING");
+    }
+
+    @Test
+    void testRequiresDeletionVectors() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "false");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining("requires deletion-vectors.enabled = 
true");
+    }
+
+    @Test
+    void testSupportsFirstRowWithoutDeletionVectors() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.MERGE_ENGINE.key(), "first-row");
+        options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "false");
+
+        assertThatCode(() -> 
validateTableSchema(schema(options))).doesNotThrowAnyException();
+    }
+
+    @Test
+    void testRejectsDeletionVectorMergeOnRead() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.DELETION_VECTORS_MERGE_ON_READ.key(), "true");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining("requires deletion-vectors.merge-on-read 
= false");
+    }
+
+    @Test
+    void testRejectsDynamicBucket() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.BUCKET.key(), "-1");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining("requires fixed or postpone bucket 
mode");
+    }
+
+    @Test
+    void testSupportsPostponeBucket() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.BUCKET.key(), "-2");
+
+        assertThatCode(() -> 
validateTableSchema(schema(options))).doesNotThrowAnyException();
+    }
+
+    @Test
+    void testRejectsPkClusteringOverride() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.PK_CLUSTERING_OVERRIDE.key(), "true");
+        options.put(CoreOptions.CLUSTERING_COLUMNS.key(), "content");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining("does not support 
pk-clustering-override");
+    }
+
+    @Test
+    void testRejectsColumnConfiguredForAnotherPrimaryKeyIndexFamily() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.PK_BTREE_INDEX_COLUMNS.key(), "content");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining("content")
+                .hasMessageContaining("at most one primary-key index");
+    }
+
+    @Test
+    void testRejectsInvalidLsmCompactionOptions() {
+        Map<String, String> options = enabledOptions();
+        options.put("fields.content.pk-index.compaction.level-fanout", "1");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                
.hasMessageContaining("fields.content.pk-index.compaction.level-fanout")
+                .hasMessageContaining("greater than 1");
+    }
+
+    @Test
+    void testRejectsMalformedIndexOptions() {
+        Map<String, String> options = enabledOptions();
+        options.put("fields.content.pk-full-text.index.options", "{not-json");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining(
+                        "fields.content.pk-full-text.index.options must be a 
JSON object");
+    }
+
+    @Test
+    void testRejectsConflictingGlobalAndFieldIndexOptions() {
+        Map<String, String> options = enabledOptions();
+        options.put("full-text.tokenizer", "standard");
+        options.put("fields.content.pk-full-text.index.options", 
"{\"tokenizer\":\"jieba\"}");
+
+        assertThatThrownBy(() -> validateTableSchema(schema(options)))
+                .hasMessageContaining(
+                        "fields.content.pk-full-text.index.options defines 
conflicting values for full-text.tokenizer");
+    }
+
+    @Test
+    void testRequiresPrimaryKeyTable() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.BUCKET_KEY.key(), "id");
+
+        TableSchema appendTable =
+                new TableSchema(
+                        0,
+                        fields(),
+                        0,
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        options,
+                        "");
+
+        assertThatThrownBy(() -> validateTableSchema(appendTable))
+                .hasMessageContaining("Primary-key full-text index requires a 
primary-key table");
+    }
+
+    private static Map<String, String> enabledOptions() {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.BUCKET.key(), "1");
+        options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+        options.put(CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key(), "content");
+        return options;
+    }
+
+    private static java.util.List<DataField> fields() {
+        return Arrays.asList(
+                new DataField(0, "id", DataTypes.INT().notNull()),
+                new DataField(1, "content", DataTypes.STRING()),
+                new DataField(2, "other_content", DataTypes.STRING()));
+    }
+
+    private static TableSchema schema(Map<String, String> options) {
+        return new TableSchema(
+                0,
+                fields(),
+                0,
+                Collections.emptyList(),
+                Collections.singletonList("id"),
+                options,
+                "");
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java 
b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java
index 9cb401c98e..1b3140a616 100644
--- a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaManagerTest.java
@@ -226,6 +226,42 @@ public class SchemaManagerTest {
                 .hasMessage("Cannot rename primary-key index column: [name]");
     }
 
+    @Test
+    public void testRejectDestructivePrimaryKeyFullTextIndexColumnChanges() 
throws Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.BUCKET.key(), "1");
+        options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+        options.put(CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS.key(), "content");
+        Schema schema =
+                new Schema(
+                        Arrays.asList(
+                                new DataField(0, "id", 
DataTypes.INT().notNull()),
+                                new DataField(1, "content", 
DataTypes.STRING())),
+                        Collections.emptyList(),
+                        Collections.singletonList("id"),
+                        options,
+                        "");
+        SchemaManager manager = new SchemaManager(LocalFileIO.create(), path);
+        manager.createTable(schema);
+
+        assertThatThrownBy(
+                        () ->
+                                manager.commitChanges(
+                                        SchemaChange.renameColumn(
+                                                new String[] {"content"}, 
"renamed_content")))
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessage("Cannot rename primary-key index column: 
[content]");
+        assertThatThrownBy(() -> 
manager.commitChanges(SchemaChange.dropColumn("content")))
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessage("Cannot drop primary-key index column: [content]");
+        assertThatThrownBy(
+                        () ->
+                                manager.commitChanges(
+                                        
SchemaChange.updateColumnType("content", DataTypes.INT())))
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessage("Cannot update type of primary-key index column: 
[content]");
+    }
+
     @Test
     public void testRejectDropPrimaryKeyBitmapIndexColumn() throws Exception {
         Map<String, String> options = new HashMap<>();


Reply via email to