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

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


The following commit(s) were added to refs/heads/master by this push:
     new 83f76f2d52d feat: add Apache TsFile input format extension (#20729)
83f76f2d52d is described below

commit 83f76f2d52da8f0c53d13fef0fb3e6ef8436f57f
Author: Colin Lee <[email protected]>
AuthorDate: Sun Nov 1 10:46:33 2026 +0800

    feat: add Apache TsFile input format extension (#20729)
    
    * feat: add Apache TsFile input format extension
    
    Adds extensions-contrib/tsfile-extensions, a community extension that lets
    batch ingestion read Apache TsFile files through the TsFile Java table 
model.
    
    - InputFormat type "tsfile" with optional tables, columns, and interval.
    - interval is pushed down to the TsFile reader, which skips chunks outside
      the range using the time statistics stored with each chunk.
    - Ships a sample TsFile, an ingestion spec, and documentation under
      docs/development/extensions-contrib/tsfile.md.
    
    * build: bump tsfile extension parent to 40.0.0-SNAPSHOT
    
    Druid master moved to 40.0.0-SNAPSHOT, so the module's parent version has to
    match; otherwise Maven cannot resolve the parent POM.
    
    * docs: add spelling suppressions for the TsFile input format docs
    
    * build: fix strict compilation and dependency analysis for tsfile extension
    
    - Drop the unused throws clause that Error Prone rejects under 
strictCompile.
    - Manage the xz and lz4-java overrides instead of declaring them, and remove
      dependencies the module does not use, so maven-dependency-plugin:analyze 
passes.
    
    * fix: address review feedback on the tsfile input format
    
    - Reject files with no table schema instead of silently reading zero rows, 
so
      tree-model files fail with a clear error.
    - Apply the timestamp alias after the measurement columns so a TsFile column
      named like the timestampSpec column cannot override the file's own time.
    - Close the TsFile reader when table validation fails.
    
    * build: bump xz to 1.10 and drop the tsfile module's version overrides
    
    TsFile 2.5.0 requires xz 1.10, so upgrade it in the root pom instead of
    overriding it in the extension. lz4-java is already at 1.12.0 upstream, so 
the
    module needs no dependencyManagement at all.
    
    * build: update the xz version in licenses.yaml to 1.10
    
    The root pom now manages xz 1.10, which packaging-check compares against the
    license manifest.
    
    * build: quote the xz version in licenses.yaml
    
    YAML parses an unquoted 1.10 as the float 1.1, so the manifest did not match
    the resolved artifact version.
    
    * test: add end-to-end ingestion tests for the tsfile input format
    
    Covers batch ingestion with a local input source and MSQ INSERT ... SELECT
    from TABLE(EXTERN(...)) against a TsFile written by the test.
    
    * build: manage the tsfile version in the root pom
    
    The embedded tests use TsFile writer classes directly, so declare the
    dependency there too. Manage the version once in the root pom instead of
    repeating it in each module.
    
    * docs: add a SQL-based ingestion example for the tsfile input format
    
    * fix: register the tsfile dependencies and honor the column projection
    
    - Add an Apache TsFile entry to licenses.yaml so the contrib bundle carries
      the licenses and notice for org.apache.tsfile:tsfile and :common.
    - When columns is omitted, project the physical columns using the ingestion
      schema's ColumnsFilter, and for EXTERN (ColumnsFilter.all()) use the fixed
      dimension list that carries the external table's column signature.
    - Fall back to reading every column when the projection would only contain 
tag
      columns, since the TsFile table reader returns no rows in that case.
    
    * fix: keep the ingestion column filter authoritative for projections
    
    - Use the fixed dimension list (the EXTERN signature) only when the
      ColumnsFilter carries no information. A batch filter that happens to cover
      every physical column now stays authoritative, so transform inputs and
      aggregator inputs are no longer dropped.
    - Remove the metric-output-names fallback, which cannot stand in for the
      aggregator input columns.
    - Stop widening tag-only projections to every physical column. A single
      supported measurement column is read as a row carrier and kept out of the
      row, so an omitted unsupported column is never read.
---
 distribution/pom.xml                               |   2 +
 docs/configuration/extensions.md                   |   1 +
 docs/development/extensions-contrib/tsfile.md      | 167 ++++++++
 embedded-tests/pom.xml                             |  11 +
 .../embedded/tsfile/TsFileIngestionTest.java       | 205 +++++++++
 extensions-contrib/tsfile-extensions/README.md     | 123 ++++++
 .../tsfile-extensions/examples/data/sensor.tsfile  | Bin 0 -> 1463 bytes
 .../tsfile-extensions/examples/sensor-ingest.json  |  49 +++
 extensions-contrib/tsfile-extensions/pom.xml       |  98 +++++
 .../data/input/tsfile/TsFileExtensionsModule.java  |  48 +++
 .../data/input/tsfile/TsFileInputEntityReader.java | 462 +++++++++++++++++++++
 .../druid/data/input/tsfile/TsFileInputFormat.java | 148 +++++++
 .../org.apache.druid.initialization.DruidModule    |  16 +
 .../data/input/tsfile/TsFileInputFormatTest.java   | 367 ++++++++++++++++
 .../druid/data/input/tsfile/TsFileTestData.java    | 149 +++++++
 licenses.yaml                                      |  17 +
 pom.xml                                            |   8 +
 website/.spelling                                  |  10 +
 18 files changed, 1881 insertions(+)

diff --git a/distribution/pom.xml b/distribution/pom.xml
index 7202d8910f8..ff0ead00c6a 100644
--- a/distribution/pom.xml
+++ b/distribution/pom.xml
@@ -461,6 +461,8 @@
                                         <argument>-c</argument>
                                         
<argument>org.apache.druid.extensions.contrib:druid-thrift-extensions</argument>
                                         <argument>-c</argument>
+                                        
<argument>org.apache.druid.extensions.contrib:druid-tsfile-extensions</argument>
+                                        <argument>-c</argument>
                                         
<argument>org.apache.druid.extensions.contrib:druid-time-min-max</argument>
                                         <argument>-c</argument>
                                         
<argument>org.apache.druid.extensions.contrib:druid-virtual-columns</argument>
diff --git a/docs/configuration/extensions.md b/docs/configuration/extensions.md
index 89102efa4ec..e4b485f25c5 100644
--- a/docs/configuration/extensions.md
+++ b/docs/configuration/extensions.md
@@ -95,6 +95,7 @@ All of these community extensions can be downloaded using 
[pull-deps](../operati
 |kafka-emitter|Kafka metrics 
emitter|[link](../development/extensions-contrib/kafka-emitter.md)|
 |openlineage-emitter|OpenLineage data lineage 
emitter|[link](../development/extensions-contrib/openlineage-emitter.md)|
 |druid-thrift-extensions|Support thrift ingestion 
|[link](../development/extensions-contrib/thrift.md)|
+|druid-tsfile-extensions|Support for ingesting Apache TsFile 
files|[link](../development/extensions-contrib/tsfile.md)|
 |druid-opentsdb-emitter|OpenTSDB metrics emitter 
|[link](../development/extensions-contrib/opentsdb-emitter.md)|
 |druid-moving-average-query|Support for [Moving 
Average](https://en.wikipedia.org/wiki/Moving_average) and other Aggregate 
[Window 
Functions](https://en.wikibooks.org/wiki/Structured_Query_Language/Window_functions)
 in Druid 
queries.|[link](../development/extensions-contrib/moving-average-query.md)|
 |druid-influxdb-emitter|InfluxDB metrics 
emitter|[link](../development/extensions-contrib/influxdb-emitter.md)|
diff --git a/docs/development/extensions-contrib/tsfile.md 
b/docs/development/extensions-contrib/tsfile.md
new file mode 100644
index 00000000000..21a9240e048
--- /dev/null
+++ b/docs/development/extensions-contrib/tsfile.md
@@ -0,0 +1,167 @@
+---
+id: tsfile
+title: "Apache TsFile input format"
+---
+
+<!--
+  ~ 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.
+  -->
+
+To use this Apache Druid extension, 
[include](../../configuration/extensions.md#loading-extensions) 
`druid-tsfile-extensions` in the extensions load list.
+
+This extension enables Druid to ingest [Apache 
TsFile](https://tsfile.apache.org/) files. TsFile is the columnar
+storage format for time series data used by [Apache 
IoTDB](https://iotdb.apache.org/). It is read through the
+TsFile Java library, version 2.5.0.
+
+The extension supports batch ingestion from local files and from any input 
source that Druid can read as a file
+(for example, S3). Local files are read in place; files from remote input 
sources are first fetched to the task's
+temporary directory, so a non-local input source needs enough local disk for 
one file at a time.
+
+## TsFile input format
+
+| Field | Type | Description | Required |
+|-------|------|-------------|----------|
+| type | String | Must be `tsfile` | yes |
+| tables | Array of String | Tables to read. When omitted or empty, all tables 
in the file are read. | no |
+| columns | Array of String | Columns to read. When omitted or empty, the 
columns implied by the ingestion schema are read: the dimensions and metrics of 
the ingestion spec, or the external table's column signature for SQL ingestion. 
All columns are read when the schema does not pin them down. | no |
+| interval | String | Time range to read, in Druid interval format 
(`start/end`). The range is end-exclusive. When omitted, each file is read in 
full. | no |
+
+### Time range pruning
+
+When `interval` is set, it is pushed down to the TsFile reader, which uses the 
time statistics stored with each
+chunk to skip chunks that fall outside the range. Only matching rows are 
returned.
+
+Druid does not pass the task's time window to input formats, so the range has 
to be set explicitly here. Rows are
+still filtered by the granularity spec as usual.
+
+### Timestamp
+
+TsFile stores its own timestamp, which is exposed to Druid under the column 
name `Time`. The ingestion spec must
+therefore select `Time` as the timestamp column with a numeric format, for 
example:
+
+```json
+"timestampSpec": {
+  "column": "Time",
+  "format": "millis"
+}
+```
+
+If `timestampSpec.column` is set to another name, the TsFile timestamp is also 
placed under that name so that
+existing ingestion specs keep working. In that case a TsFile column with the 
same name is shadowed.
+
+### Column types
+
+Tag, field, and attribute columns are all converted to input rows. The mapping 
of TsFile data types is:
+
+| TsFile type | Druid value |
+|-------------|-------------|
+| INT32 | Integer |
+| INT64 | Long |
+| FLOAT | Float |
+| DOUBLE | Double |
+| BOOLEAN | Boolean |
+| TEXT, STRING | String |
+| TIMESTAMP | Long (epoch milliseconds) |
+| DATE | Integer (epoch day) |
+| BLOB | Base64-encoded String |
+
+`VECTOR`, `OBJECT`, `ROW`, and `UNKNOWN` columns are not supported; requesting 
one of them fails the ingestion task.
+Null values are omitted from the row.
+
+## Example: local batch ingestion
+
+Suppose a TsFile file `sensor.tsfile` contains a table `sensor` with the tag 
columns `region` and `device` and the
+value columns `temperature`, `humidity`, and `status`. The following spec 
ingests it:
+
+```json
+{
+  "type": "index_parallel",
+  "spec": {
+    "ioConfig": {
+      "type": "index_parallel",
+      "inputSource": {
+        "type": "local",
+        "baseDir": "/data/tsfile",
+        "filter": "*.tsfile"
+      },
+      "inputFormat": {
+        "type": "tsfile",
+        "tables": ["sensor"],
+        "columns": ["region", "device", "temperature", "humidity", "status"]
+      }
+    },
+    "dataSchema": {
+      "dataSource": "sensor",
+      "timestampSpec": {
+        "column": "Time",
+        "format": "millis"
+      },
+      "dimensionsSpec": {
+        "dimensions": ["region", "device"]
+      },
+      "metricsSpec": [
+        {"type": "doubleSum", "name": "temperature", "fieldName": 
"temperature"},
+        {"type": "doubleSum", "name": "humidity", "fieldName": "humidity"}
+      ],
+      "granularitySpec": {
+        "type": "uniform",
+        "segmentGranularity": "DAY",
+        "queryGranularity": "MINUTE",
+        "rollup": true
+      }
+    },
+    "tuningConfig": {
+      "type": "index_parallel"
+    }
+  }
+}
+```
+
+## Example: SQL-based ingestion
+
+The same file can be loaded with an MSQ `INSERT ... SELECT` statement. 
`EXTERN` takes the input source spec, the
+input format spec, and the external table's column signature:
+
+```sql
+INSERT INTO sensor
+SELECT
+  MILLIS_TO_TIMESTAMP("Time") AS __time,
+  region,
+  device,
+  temperature
+FROM TABLE(
+  EXTERN(
+    '{"type":"local","baseDir":"/data/tsfile","filter":"*.tsfile"}',
+    '{"type":"tsfile","tables":["sensor"]}',
+    '[{"name":"Time","type":"long"},{"name":"region","type":"string"},'
+    '{"name":"device","type":"string"},{"name":"temperature","type":"double"}]'
+  )
+)
+PARTITIONED BY DAY
+```
+
+TsFile's implicit `Time` column is declared as `long` in the signature and 
converted to `__time` with
+`MILLIS_TO_TIMESTAMP`. Columns that are not listed in the signature are not 
read.
+
+## Limitations
+
+- The input format is not splittable. With a splittable input source such as 
`local`, `s3`, or `gcs`, Druid still
+  parallelizes ingestion one file per task.
+- Druid's segment granularity is not pushed down to TsFile. To read only part 
of a file, set `interval`.
+- A single ingestion spec reads one row schema. If a file contains several 
tables with different columns, either
+  select one table with `tables` or ingest each table separately.
diff --git a/embedded-tests/pom.xml b/embedded-tests/pom.xml
index 33e97ece929..9b1766c4765 100644
--- a/embedded-tests/pom.xml
+++ b/embedded-tests/pom.xml
@@ -376,6 +376,17 @@
       <version>${project.parent.version}</version>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.apache.druid.extensions.contrib</groupId>
+      <artifactId>druid-tsfile-extensions</artifactId>
+      <version>${project.parent.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.tsfile</groupId>
+      <artifactId>tsfile</artifactId>
+      <scope>test</scope>
+    </dependency>
     <dependency>
       <groupId>org.apache.druid.extensions</groupId>
       <artifactId>druid-hdfs-storage</artifactId>
diff --git 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/tsfile/TsFileIngestionTest.java
 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/tsfile/TsFileIngestionTest.java
new file mode 100644
index 00000000000..f5c1982c352
--- /dev/null
+++ 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/tsfile/TsFileIngestionTest.java
@@ -0,0 +1,205 @@
+/*
+ * 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.druid.testing.embedded.tsfile;
+
+import org.apache.druid.data.input.tsfile.TsFileExtensionsModule;
+import org.apache.druid.data.input.tsfile.TsFileInputFormat;
+import org.apache.druid.indexing.common.task.IndexTask;
+import org.apache.druid.indexing.common.task.TaskBuilder;
+import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.query.aggregation.DoubleSumAggregatorFactory;
+import org.apache.druid.query.http.SqlTaskStatus;
+import org.apache.druid.testing.embedded.EmbeddedBroker;
+import org.apache.druid.testing.embedded.EmbeddedClusterApis;
+import org.apache.druid.testing.embedded.EmbeddedCoordinator;
+import org.apache.druid.testing.embedded.EmbeddedDruidCluster;
+import org.apache.druid.testing.embedded.EmbeddedHistorical;
+import org.apache.druid.testing.embedded.EmbeddedIndexer;
+import org.apache.druid.testing.embedded.EmbeddedOverlord;
+import org.apache.druid.testing.embedded.junit5.EmbeddedClusterTestBase;
+import org.apache.druid.testing.embedded.msq.EmbeddedMSQApis;
+import org.apache.tsfile.enums.ColumnCategory;
+import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.exception.write.WriteProcessException;
+import org.apache.tsfile.file.metadata.ColumnSchemaBuilder;
+import org.apache.tsfile.file.metadata.TableSchema;
+import org.apache.tsfile.write.record.Tablet;
+import org.apache.tsfile.write.v4.ITsFileWriter;
+import org.apache.tsfile.write.v4.TsFileWriterBuilder;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Arrays;
+
+/**
+ * End-to-end ingestion tests for {@link TsFileInputFormat} running inside an 
embedded Druid cluster.
+ * The TsFile written by {@link #createTsFile()} holds three rows of one 
table, mirroring the sample file
+ * shipped with the extension.
+ */
+public class TsFileIngestionTest extends EmbeddedClusterTestBase
+{
+  private static final String TABLE_NAME = "sensor";
+  private static final int ROW_COUNT = 3;
+
+  private final EmbeddedOverlord overlord = new EmbeddedOverlord();
+  private final EmbeddedCoordinator coordinator = new EmbeddedCoordinator();
+  private final EmbeddedIndexer indexer = new EmbeddedIndexer()
+      .setServerMemory(300_000_000L)
+      .addProperty("druid.worker.capacity", "2");
+  private final EmbeddedBroker broker = new EmbeddedBroker();
+
+  @TempDir
+  private File tempDir;
+
+  @Override
+  protected EmbeddedDruidCluster createCluster()
+  {
+    return EmbeddedDruidCluster
+        .withEmbeddedDerbyAndZookeeper()
+        .useLatchableEmitter()
+        .addExtension(TsFileExtensionsModule.class)
+        .addServer(overlord)
+        .addServer(coordinator)
+        .addServer(indexer)
+        .addServer(broker)
+        .addServer(new EmbeddedHistorical());
+  }
+
+  /**
+   * Batch ingestion driven by a task spec that reads a local TsFile.
+   */
+  @Test
+  @Timeout(180)
+  public void testBatchIngestionTask() throws IOException, 
WriteProcessException
+  {
+    final IndexTask task = TaskBuilder
+        .ofTypeIndex()
+        .localInputSourceWithFiles(createTsFile())
+        .inputFormat(new TsFileInputFormat(null, null, null))
+        .timestampColumn(TsFileInputFormat.TIME_COLUMN_NAME)
+        .dimensions("region", "device")
+        .metricAggregates(new DoubleSumAggregatorFactory("temperature", 
"temperature"))
+        .segmentGranularity("DAY")
+        .dataSource(dataSource)
+        .withId(EmbeddedClusterApis.newTaskId(dataSource));
+
+    cluster.callApi().runTask(task, overlord);
+    cluster.callApi().waitForAllSegmentsToBeAvailable(dataSource, coordinator, 
broker);
+
+    assertIngestedRows();
+  }
+
+  /**
+   * MSQ {@code INSERT ... SELECT FROM TABLE(EXTERN(...))} reading the same 
TsFile.
+   */
+  @Test
+  @Timeout(180)
+  public void testInsertSelectFromExtern() throws IOException, 
WriteProcessException
+  {
+    final File tsFile = createTsFile();
+    final EmbeddedMSQApis msqApis = new EmbeddedMSQApis(cluster, overlord);
+
+    // Escape backslashes so a Windows path stays valid inside the JSON input 
source spec.
+    final String baseDir = 
StringUtils.replace(tsFile.getParentFile().getAbsolutePath(), "\\", "\\\\");
+
+    final String sql = StringUtils.format(
+        "INSERT INTO %s\n"
+        + "SELECT\n"
+        + "  MILLIS_TO_TIMESTAMP(\"Time\") AS __time,\n"
+        + "  region,\n"
+        + "  device,\n"
+        + "  temperature\n"
+        + "FROM TABLE(\n"
+        + "  EXTERN(\n"
+        + "    
'{\"type\":\"local\",\"baseDir\":\"%s\",\"filter\":\"*.tsfile\"}',\n"
+        + "    '{\"type\":\"tsfile\",\"tables\":[\"%s\"]}',\n"
+        + "    
'[{\"name\":\"Time\",\"type\":\"long\"},{\"name\":\"region\",\"type\":\"string\"},"
+        + 
"{\"name\":\"device\",\"type\":\"string\"},{\"name\":\"temperature\",\"type\":\"double\"}]'\n"
+        + "  )\n"
+        + ")\n"
+        + "PARTITIONED BY DAY",
+        dataSource,
+        baseDir,
+        TABLE_NAME
+    );
+
+    final SqlTaskStatus taskStatus = msqApis.submitTaskSql(sql);
+    cluster.callApi().waitForTaskToSucceed(taskStatus.getTaskId(), overlord);
+    cluster.callApi().waitForAllSegmentsToBeAvailable(dataSource, coordinator, 
broker);
+
+    assertIngestedRows();
+    cluster.callApi().verifySqlQuery(
+        "SELECT SUM(temperature) FROM %s",
+        dataSource,
+        String.valueOf(21.5 + 22.5 + 23.5)
+    );
+  }
+
+  private void assertIngestedRows()
+  {
+    cluster.callApi().verifySqlQuery(
+        "SELECT COUNT(*) FROM %s",
+        dataSource,
+        String.valueOf(ROW_COUNT)
+    );
+    cluster.callApi().verifySqlQuery(
+        "SELECT region, COUNT(*) FROM %s GROUP BY region ORDER BY region",
+        dataSource,
+        "beijing,2\nshanghai,1"
+    );
+    cluster.callApi().verifySqlQuery(
+        "SELECT MIN(__time), MAX(__time) FROM %s",
+        dataSource,
+        "1970-01-01T00:00:01.000Z,1970-01-01T00:00:01.002Z"
+    );
+  }
+
+  private File createTsFile() throws IOException, WriteProcessException
+  {
+    final File file = new File(tempDir, "sensor.tsfile");
+    final TableSchema tableSchema = new TableSchema(
+        TABLE_NAME,
+        Arrays.asList(
+            new 
ColumnSchemaBuilder().name("region").dataType(TSDataType.STRING).category(ColumnCategory.TAG).build(),
+            new 
ColumnSchemaBuilder().name("device").dataType(TSDataType.STRING).category(ColumnCategory.TAG).build(),
+            new 
ColumnSchemaBuilder().name("temperature").dataType(TSDataType.FLOAT).build()
+        )
+    );
+
+    final Tablet tablet = new Tablet(
+        Arrays.asList("region", "device", "temperature"),
+        Arrays.asList(TSDataType.STRING, TSDataType.STRING, TSDataType.FLOAT)
+    );
+    for (int i = 0; i < ROW_COUNT; i++) {
+      tablet.addTimestamp(i, 1000L + i);
+      tablet.addValue(i, "region", i < 2 ? "beijing" : "shanghai");
+      tablet.addValue(i, "device", "d" + (i + 1));
+      tablet.addValue(i, "temperature", 21.5f + i);
+    }
+
+    try (ITsFileWriter writer = new 
TsFileWriterBuilder().file(file).tableSchema(tableSchema).build()) {
+      writer.write(tablet);
+    }
+    return file;
+  }
+}
diff --git a/extensions-contrib/tsfile-extensions/README.md 
b/extensions-contrib/tsfile-extensions/README.md
new file mode 100644
index 00000000000..8bcb381775f
--- /dev/null
+++ b/extensions-contrib/tsfile-extensions/README.md
@@ -0,0 +1,123 @@
+<!--
+  ~ 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.
+  -->
+
+# druid-tsfile-extensions
+
+Community extension that lets Druid batch ingestion read [Apache 
TsFile](https://tsfile.apache.org/) files.
+
+User documentation: 
[docs/development/extensions-contrib/tsfile.md](../../docs/development/extensions-contrib/tsfile.md).
+
+## Building
+
+```bash
+mvn -pl extensions-contrib/tsfile-extensions -am package
+```
+
+## Compatibility
+
+| Item | Version |
+|------|---------|
+| TsFile Java library | `org.apache.tsfile:tsfile:2.5.0` |
+| TsFile format | Files written by the TsFile 2.x table model (table schemas 
in the file footer) |
+| Druid | 40.0.0 |
+
+The extension reads the table model. Files that only contain the legacy tree 
model (device paths such as
+`root.sg.d1.s1` without a table schema in the footer) are not supported.
+
+## Dependencies and licenses
+
+The extension adds the following runtime dependencies, all Apache-2.0 
compatible:
+
+| Dependency | License |
+|------------|---------|
+| `org.apache.tsfile:tsfile` | Apache-2.0 |
+| `org.apache.tsfile:common` | Apache-2.0 |
+| `com.github.luben:zstd-jni` | BSD-2-Clause (managed by Druid) |
+| `org.xerial.snappy:snappy-java` | Apache-2.0 (managed by Druid) |
+| `at.yawk.lz4:lz4-java` | Apache-2.0 (managed by Druid) |
+| `org.tukaani:xz` | Public domain (managed by Druid) |
+| `org.antlr:antlr4-runtime` | BSD-3-Clause (managed by Druid) |
+
+Only `org.apache.tsfile:tsfile` and `org.apache.tsfile:common` are new to 
Druid; the compression and parser
+libraries are already shipped by Druid.
+
+## Sample data
+
+`examples/data/sensor.tsfile` is a three-row TsFile written by 
`TsFileTestData`, and `examples/sensor-ingest.json`
+is an ingestion spec for it. The file contains one table, `sensor`:
+
+| Column | TsFile category | TsFile type |
+|--------|-----------------|-------------|
+| `region` | TAG | STRING |
+| `device` | TAG | STRING |
+| `temperature` | FIELD | FLOAT |
+| `humidity` | FIELD | DOUBLE |
+| `status` | FIELD | BOOLEAN |
+
+Expected input rows:
+
+| `Time` | `region` | `device` | `temperature` | `humidity` | `status` |
+|--------|----------|----------|---------------|------------|----------|
+| 1000 | beijing | d1 | 21.5 | 0.5 | true |
+| 2000 | beijing | d2 | 22.5 | *(null)* | false |
+| 3000 | shanghai | d3 | 23.5 | 0.7 | true |
+
+`Time` is in epoch milliseconds. The second row has no humidity value; TsFile 
returns it as null and the extension
+omits it from the row.
+
+To regenerate the sample file from this module:
+
+```bash
+mvn exec:java -Dexec.classpathScope=test \
+  -Dexec.mainClass=org.apache.druid.data.input.tsfile.TsFileTestData
+```
+
+## Test plan
+
+`TsFileInputFormatTest` covers:
+
+| Test | What it checks |
+|------|----------------|
+| `testReadAllColumns` | All tag and field columns, timestamps, and a missing 
(null) value are read correctly |
+| `testReadWithDefaultTimestampColumn` | The TsFile timestamp is also 
available under a non-default `timestampSpec` column |
+| `testReadSelectedTableAndColumns` | `tables` and `columns` restrict the read 
|
+| `testProjectionFromColumnsFilter` | The ingestion schema's column filter 
restricts the physical columns read |
+| `testProjectionFromFixedDimensions` | A fixed dimension list (the EXTERN 
signature) restricts the physical columns read |
+| `testColumnsFilterWinsOverFixedDimensions` | A batch ingestion filter keeps 
every column it lists, even when the dimension list is narrower |
+| `testTagOnlyProjectionUsesRowCarrier` | A tag-only projection still returns 
rows, via a row carrier that is not exposed |
+| `testIntervalPushdown` | An `interval` restricts the read to rows inside it |
+| `testIntervalIsEndExclusive` | A row at the end boundary of `interval` is 
excluded |
+| `testEmptyIntervalReadsNothing` | An empty `interval` yields no rows |
+| `testReadCommittedSampleFile` | The committed `examples/data/sensor.tsfile` 
matches the expected rows below |
+| `testTreeModelFileIsRejected` | A tree-model file without a table schema 
fails with a clear error instead of reading zero rows |
+| `testTimestampAliasShadowsSameNamedColumn` | The TsFile time wins over a 
TsFile column named like the `timestampSpec` column |
+| `testUnknownTable` | A missing table produces an `InvalidInputException` 
naming the table |
+| `testUnknownColumn` | A missing column produces an `InvalidInputException` 
naming the column |
+| `testSerde` | The input format round-trips through Jackson with `"type": 
"tsfile"` |
+
+To run them:
+
+```bash
+mvn -pl extensions-contrib/tsfile-extensions -am test 
-Dtest=TsFileInputFormatTest
+```
+
+## Maintenance
+
+This is a community extension. The Apache TsFile community owns maintenance 
and compatibility testing, as agreed
+with the Druid community on 
[[email protected]](https://lists.apache.org/thread/qmrkhobp7p07wkmcf4y4myq36k866zz0).
diff --git a/extensions-contrib/tsfile-extensions/examples/data/sensor.tsfile 
b/extensions-contrib/tsfile-extensions/examples/data/sensor.tsfile
new file mode 100644
index 00000000000..16b5a65419f
Binary files /dev/null and 
b/extensions-contrib/tsfile-extensions/examples/data/sensor.tsfile differ
diff --git a/extensions-contrib/tsfile-extensions/examples/sensor-ingest.json 
b/extensions-contrib/tsfile-extensions/examples/sensor-ingest.json
new file mode 100644
index 00000000000..f5bb1d6bbe4
--- /dev/null
+++ b/extensions-contrib/tsfile-extensions/examples/sensor-ingest.json
@@ -0,0 +1,49 @@
+{
+  "type": "index_parallel",
+  "spec": {
+    "ioConfig": {
+      "type": "index_parallel",
+      "inputSource": {
+        "type": "local",
+        "baseDir": 
"/path/to/druid/extensions-contrib/tsfile-extensions/examples/data",
+        "filter": "*.tsfile"
+      },
+      "inputFormat": {
+        "type": "tsfile",
+        "tables": ["sensor"],
+        "columns": ["region", "device", "temperature", "humidity", "status"]
+      }
+    },
+    "dataSchema": {
+      "dataSource": "sensor",
+      "timestampSpec": {
+        "column": "Time",
+        "format": "millis"
+      },
+      "dimensionsSpec": {
+        "dimensions": ["region", "device", "status"]
+      },
+      "metricsSpec": [
+        {
+          "type": "doubleSum",
+          "name": "temperature",
+          "fieldName": "temperature"
+        },
+        {
+          "type": "doubleSum",
+          "name": "humidity",
+          "fieldName": "humidity"
+        }
+      ],
+      "granularitySpec": {
+        "type": "uniform",
+        "segmentGranularity": "DAY",
+        "queryGranularity": "MINUTE",
+        "rollup": false
+      }
+    },
+    "tuningConfig": {
+      "type": "index_parallel"
+    }
+  }
+}
diff --git a/extensions-contrib/tsfile-extensions/pom.xml 
b/extensions-contrib/tsfile-extensions/pom.xml
new file mode 100644
index 00000000000..83f58719219
--- /dev/null
+++ b/extensions-contrib/tsfile-extensions/pom.xml
@@ -0,0 +1,98 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  -->
+
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
+
+  <groupId>org.apache.druid.extensions.contrib</groupId>
+  <artifactId>druid-tsfile-extensions</artifactId>
+  <name>druid-tsfile-extensions</name>
+  <description>Apache TsFile input format for Druid batch 
ingestion</description>
+
+  <parent>
+    <artifactId>druid</artifactId>
+    <groupId>org.apache.druid</groupId>
+    <version>40.0.0-SNAPSHOT</version>
+    <relativePath>../../pom.xml</relativePath>
+  </parent>
+  <modelVersion>4.0.0</modelVersion>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.apache.druid</groupId>
+      <artifactId>druid-processing</artifactId>
+      <version>${project.parent.version}</version>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.tsfile</groupId>
+      <artifactId>tsfile</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>com.fasterxml.jackson.core</groupId>
+      <artifactId>jackson-annotations</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.fasterxml.jackson.core</groupId>
+      <artifactId>jackson-databind</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.google.inject</groupId>
+      <artifactId>guice</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.google.code.findbugs</groupId>
+      <artifactId>jsr305</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>joda-time</groupId>
+      <artifactId>joda-time</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-api</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-engine</artifactId>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-dependency-plugin</artifactId>
+        <configuration>
+          <ignoredUnusedDeclaredDependencies>
+            <!-- Runtime provider for the JUnit 5 tests; declared so that 
surefire can run them. -->
+            
<ignoredUnusedDeclaredDependency>org.junit.jupiter:junit-jupiter-engine</ignoredUnusedDeclaredDependency>
+          </ignoredUnusedDeclaredDependencies>
+        </configuration>
+      </plugin>
+    </plugins>
+  </build>
+</project>
diff --git 
a/extensions-contrib/tsfile-extensions/src/main/java/org/apache/druid/data/input/tsfile/TsFileExtensionsModule.java
 
b/extensions-contrib/tsfile-extensions/src/main/java/org/apache/druid/data/input/tsfile/TsFileExtensionsModule.java
new file mode 100644
index 00000000000..8dc4d7413ee
--- /dev/null
+++ 
b/extensions-contrib/tsfile-extensions/src/main/java/org/apache/druid/data/input/tsfile/TsFileExtensionsModule.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.data.input.tsfile;
+
+import com.fasterxml.jackson.databind.Module;
+import com.fasterxml.jackson.databind.jsontype.NamedType;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.google.inject.Binder;
+import org.apache.druid.initialization.DruidModule;
+
+import java.util.Collections;
+import java.util.List;
+
+public class TsFileExtensionsModule implements DruidModule
+{
+  @Override
+  public List<? extends Module> getJacksonModules()
+  {
+    return Collections.singletonList(
+        new SimpleModule("TsFileInputRowParserModule")
+            .registerSubtypes(
+                new NamedType(TsFileInputFormat.class, "tsfile")
+            )
+    );
+  }
+
+  @Override
+  public void configure(Binder binder)
+  {
+  }
+}
diff --git 
a/extensions-contrib/tsfile-extensions/src/main/java/org/apache/druid/data/input/tsfile/TsFileInputEntityReader.java
 
b/extensions-contrib/tsfile-extensions/src/main/java/org/apache/druid/data/input/tsfile/TsFileInputEntityReader.java
new file mode 100644
index 00000000000..1925a056815
--- /dev/null
+++ 
b/extensions-contrib/tsfile-extensions/src/main/java/org/apache/druid/data/input/tsfile/TsFileInputEntityReader.java
@@ -0,0 +1,462 @@
+/*
+ * 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.druid.data.input.tsfile;
+
+import org.apache.druid.data.input.ColumnsFilter;
+import org.apache.druid.data.input.InputEntity;
+import org.apache.druid.data.input.InputRow;
+import org.apache.druid.data.input.InputRowSchema;
+import org.apache.druid.data.input.IntermediateRowParsingReader;
+import org.apache.druid.data.input.impl.DimensionsSpec;
+import org.apache.druid.data.input.impl.MapInputRowParser;
+import org.apache.druid.error.InvalidInput;
+import org.apache.druid.java.util.common.CloseableIterators;
+import org.apache.druid.java.util.common.parsers.CloseableIterator;
+import org.apache.druid.java.util.common.parsers.ParseException;
+import org.apache.tsfile.enums.ColumnCategory;
+import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.exception.read.ReadProcessException;
+import org.apache.tsfile.exception.write.NoMeasurementException;
+import org.apache.tsfile.exception.write.NoTableException;
+import org.apache.tsfile.file.metadata.TableSchema;
+import org.apache.tsfile.read.query.dataset.ResultSet;
+import org.apache.tsfile.read.v4.ITsFileReader;
+import org.apache.tsfile.read.v4.TsFileReaderBuilder;
+import org.apache.tsfile.write.schema.IMeasurementSchema;
+import org.joda.time.Interval;
+
+import javax.annotation.Nullable;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Reads the rows of a TsFile file with the TsFile table-model reader and 
converts them into
+ * {@link MapBasedInputRow}-compatible maps.
+ * <p>
+ * TsFile stores its own timestamp, which is exposed to Druid under the name 
{@value TsFileInputFormat#TIME_COLUMN_NAME}.
+ * Tag, field, and attribute columns are exposed as-is.
+ */
+public class TsFileInputEntityReader extends 
IntermediateRowParsingReader<Map<String, Object>>
+{
+  private final InputRowSchema inputRowSchema;
+  private final InputEntity source;
+  @Nullable
+  private final File temporaryDirectory;
+  @Nullable
+  private final List<String> tables;
+  @Nullable
+  private final List<String> columns;
+  @Nullable
+  private final Interval interval;
+
+  TsFileInputEntityReader(
+      InputRowSchema inputRowSchema,
+      InputEntity source,
+      @Nullable File temporaryDirectory,
+      @Nullable List<String> tables,
+      @Nullable List<String> columns,
+      @Nullable Interval interval
+  )
+  {
+    this.inputRowSchema = inputRowSchema;
+    this.source = source;
+    this.temporaryDirectory = temporaryDirectory;
+    this.tables = tables;
+    this.columns = columns;
+    this.interval = interval;
+  }
+
+  @Override
+  protected CloseableIterator<Map<String, Object>> intermediateRowIterator() 
throws IOException
+  {
+    final InputEntity.CleanableFile materialized =
+        source.fetch(temporaryDirectory, new 
byte[InputEntity.DEFAULT_FETCH_BUFFER_SIZE]);
+    final ITsFileReader reader;
+    try {
+      reader = new TsFileReaderBuilder().file(materialized.file()).build();
+    }
+    catch (IOException | RuntimeException e) {
+      materialized.close();
+      throw e;
+    }
+
+    final List<String> tableNames;
+    try {
+      tableNames = resolveTableNames(reader);
+    }
+    catch (IOException | RuntimeException e) {
+      reader.close();
+      materialized.close();
+      throw e;
+    }
+
+    return CloseableIterators.wrap(
+        new RowIterator(reader, tableNames),
+        () -> {
+          reader.close();
+          materialized.close();
+        }
+    );
+  }
+
+  @Override
+  protected InputEntity source()
+  {
+    return source;
+  }
+
+  @Override
+  protected List<InputRow> parseInputRows(Map<String, Object> intermediateRow) 
throws ParseException
+  {
+    return Collections.singletonList(MapInputRowParser.parse(inputRowSchema, 
intermediateRow));
+  }
+
+  @Override
+  protected List<Map<String, Object>> toMap(Map<String, Object> 
intermediateRow)
+  {
+    return Collections.singletonList(intermediateRow);
+  }
+
+  private List<String> resolveTableNames(ITsFileReader reader) throws 
IOException
+  {
+    final List<TableSchema> allTables = reader.getAllTableSchema();
+    if (allTables.isEmpty()) {
+      throw InvalidInput.exception(
+          "TsFile[%s] contains no table schema. Only table-model TsFiles are 
supported; tree-model files "
+          + "are not.",
+          source.getUri()
+      );
+    }
+    if (tables == null || tables.isEmpty()) {
+      return 
allTables.stream().map(TableSchema::getTableName).collect(Collectors.toList());
+    }
+
+    final Set<String> available =
+        allTables.stream().map(schema -> 
schema.getTableName().toLowerCase(Locale.ROOT)).collect(Collectors.toSet());
+    for (String table : tables) {
+      if (!available.contains(table.toLowerCase(Locale.ROOT))) {
+        throw InvalidInput.exception(
+            "Table[%s] does not exist in the file. Available tables: %s",
+            table,
+            
allTables.stream().map(TableSchema::getTableName).collect(Collectors.toList())
+        );
+      }
+    }
+    return tables;
+  }
+
+  /**
+   * Columns to read for one table. {@code queryColumns} are passed to the 
TsFile reader; only
+   * {@code outputColumns} end up in the row. The two differ when a column is 
read purely to make the reader
+   * produce rows.
+   */
+  private static class TableProjection
+  {
+    private final List<String> queryColumns;
+    private final Set<String> outputColumns;
+
+    TableProjection(List<String> queryColumns, Set<String> outputColumns)
+    {
+      this.queryColumns = queryColumns;
+      this.outputColumns = outputColumns;
+    }
+  }
+
+  private TableProjection selectColumns(TableSchema tableSchema)
+  {
+    if (columns != null && !columns.isEmpty()) {
+      return new TableProjection(columns, new HashSet<>(columns));
+    }
+
+    final List<IMeasurementSchema> columnSchemas = 
tableSchema.getColumnSchemas();
+    final List<ColumnCategory> columnCategories = tableSchema.getColumnTypes();
+    final List<String> physicalColumns = new ArrayList<>();
+    final Set<String> tagColumns = new HashSet<>();
+    final Set<String> supportedColumns = new HashSet<>();
+    for (int i = 0; i < columnSchemas.size(); i++) {
+      final String columnName = columnSchemas.get(i).getMeasurementName();
+      if (TsFileInputFormat.TIME_COLUMN_NAME.equalsIgnoreCase(columnName)) {
+        continue;
+      }
+      physicalColumns.add(columnName);
+      if (columnCategories.get(i) == ColumnCategory.TAG) {
+        tagColumns.add(columnName);
+      }
+      if (isSupportedType(columnSchemas.get(i).getType())) {
+        supportedColumns.add(columnName);
+      }
+    }
+
+    // The ingestion schema's filter is authoritative whenever it carries 
information: it already lists the
+    // timestamp column, the dimensions, the transforms' inputs and the 
aggregators' inputs. MSQ EXTERN always
+    // passes ColumnsFilter.all(), which carries none of that; there the 
projection comes from the fixed
+    // dimensions, i.e. the external table's column signature.
+    final ColumnsFilter columnsFilter = inputRowSchema.getColumnsFilter();
+    List<String> selected = null;
+    if (columnsFilter != null && !columnsFilter.equals(ColumnsFilter.all())) {
+      selected = 
physicalColumns.stream().filter(columnsFilter::apply).collect(Collectors.toList());
+    } else {
+      final DimensionsSpec dimensionsSpec = inputRowSchema.getDimensionsSpec();
+      if (dimensionsSpec != null && dimensionsSpec.hasFixedDimensions()) {
+        final Set<String> wanted = new 
HashSet<>(dimensionsSpec.getDimensionNames());
+        final List<String> projected = 
physicalColumns.stream().filter(wanted::contains).collect(Collectors.toList());
+        if (!projected.isEmpty()) {
+          selected = projected;
+        }
+      }
+    }
+    if (selected == null) {
+      // Nothing in the schema narrows the columns down: read everything that 
can be converted to a row value.
+      selected = 
physicalColumns.stream().filter(supportedColumns::contains).collect(Collectors.toList());
+    }
+
+    // The TsFile table reader only produces blocks while it has a measurement 
column to read, so a projection of
+    // tag columns alone would yield no rows at all. Read one supported 
measurement column as a row carrier in
+    // that case, without exposing it in the row.
+    final List<String> queryColumns = new ArrayList<>(selected);
+    if (selected.stream().noneMatch(column -> !tagColumns.contains(column))) {
+      physicalColumns.stream()
+                     .filter(column -> !tagColumns.contains(column))
+                     .filter(supportedColumns::contains)
+                     .findFirst()
+                     .ifPresent(queryColumns::add);
+    }
+
+    return new TableProjection(queryColumns, new HashSet<>(selected));
+  }
+
+  private static boolean isSupportedType(TSDataType dataType)
+  {
+    switch (dataType) {
+      case INT32:
+      case INT64:
+      case FLOAT:
+      case DOUBLE:
+      case BOOLEAN:
+      case TEXT:
+      case STRING:
+      case DATE:
+      case TIMESTAMP:
+      case BLOB:
+        return true;
+      default:
+        return false;
+    }
+  }
+
+  /**
+   * Iterates over the rows of every selected table, keeping at most one open 
{@link ResultSet} at a time.
+   */
+  private class RowIterator implements Iterator<Map<String, Object>>
+  {
+    private final ITsFileReader reader;
+    private final Iterator<String> tableIterator;
+
+    @Nullable
+    private ResultSet resultSet = null;
+    private List<String> currentColumns = null;
+    private List<TSDataType> currentTypes = null;
+    private Set<String> currentOutputColumns = null;
+    @Nullable
+    private Map<String, Object> bufferedRow = null;
+
+    RowIterator(ITsFileReader reader, List<String> tableNames)
+    {
+      this.reader = reader;
+      this.tableIterator = tableNames.iterator();
+    }
+
+    @Override
+    public boolean hasNext()
+    {
+      if (bufferedRow != null) {
+        return true;
+      }
+
+      while (true) {
+        if (resultSet != null) {
+          try {
+            if (resultSet.next()) {
+              bufferedRow = readCurrentRow();
+              return true;
+            }
+          }
+          catch (IOException e) {
+            throw new UncheckedIOException(e);
+          }
+          closeCurrentResultSet();
+        }
+
+        if (!tableIterator.hasNext()) {
+          closeCurrentResultSet();
+          return false;
+        }
+        openTable(tableIterator.next());
+      }
+    }
+
+    @Override
+    public Map<String, Object> next()
+    {
+      if (!hasNext()) {
+        throw new NoSuchElementException();
+      }
+      final Map<String, Object> row = bufferedRow;
+      bufferedRow = null;
+      return row;
+    }
+
+    private void openTable(String tableName)
+    {
+      final long queryStartTime;
+      final long queryEndTime;
+      if (interval == null) {
+        queryStartTime = Long.MIN_VALUE;
+        queryEndTime = Long.MAX_VALUE;
+      } else {
+        if (interval.getEndMillis() <= interval.getStartMillis()) {
+          // An empty interval matches no row, so there is nothing to read.
+          return;
+        }
+        // TsFile folds both time bounds into the comparison, while a Druid 
interval is end-exclusive.
+        queryStartTime = interval.getStartMillis();
+        queryEndTime = interval.getEndMillis() - 1;
+      }
+
+      final TableSchema tableSchema;
+      try {
+        tableSchema = reader.getTableSchemas(tableName)
+                            .orElseThrow(
+                                () -> InvalidInput.exception(
+                                    "Table[%s] does not exist in the file",
+                                    tableName
+                                )
+                            );
+      }
+      catch (IOException e) {
+        throw new UncheckedIOException(e);
+      }
+      final TableProjection projection = selectColumns(tableSchema);
+      final List<String> selectedColumns = projection.queryColumns;
+      final List<TSDataType> types = new ArrayList<>(selectedColumns.size());
+      for (String column : selectedColumns) {
+        final int columnIndex = tableSchema.findColumnIndex(column);
+        if (columnIndex < 0) {
+          throw InvalidInput.exception(
+              "Column[%s] does not exist in table[%s]",
+              column,
+              tableName
+          );
+        }
+        types.add(tableSchema.getColumnSchemas().get(columnIndex).getType());
+      }
+
+      try {
+        resultSet = reader.query(tableName, selectedColumns, queryStartTime, 
queryEndTime);
+      }
+      catch (IOException e) {
+        throw new UncheckedIOException(e);
+      }
+      catch (ReadProcessException | NoTableException | NoMeasurementException 
e) {
+        throw new RuntimeException(e);
+      }
+      currentColumns = selectedColumns;
+      currentTypes = types;
+      currentOutputColumns = projection.outputColumns;
+    }
+
+    private void closeCurrentResultSet()
+    {
+      if (resultSet != null) {
+        resultSet.close();
+        resultSet = null;
+      }
+    }
+
+    private Map<String, Object> readCurrentRow()
+    {
+      final Map<String, Object> row = new HashMap<>();
+
+      for (int i = 0; i < currentColumns.size(); i++) {
+        final String column = currentColumns.get(i);
+        final int resultSetIndex = i + 2;
+        if (currentOutputColumns.contains(column) && 
!resultSet.isNull(resultSetIndex)) {
+          row.put(column, readValue(resultSet, currentTypes.get(i), 
resultSetIndex));
+        }
+      }
+
+      // Written after the measurements so that a column sharing the timestamp 
column name is shadowed by it.
+      // Column index 1 of every TsFile result set is the implicit time column.
+      final long timestamp = resultSet.getLong(1);
+      row.put(TsFileInputFormat.TIME_COLUMN_NAME, timestamp);
+
+      final String timestampColumn = 
inputRowSchema.getTimestampSpec().getTimestampColumn();
+      if (!TsFileInputFormat.TIME_COLUMN_NAME.equals(timestampColumn)) {
+        row.put(timestampColumn, timestamp);
+      }
+      return row;
+    }
+  }
+
+  private static Object readValue(ResultSet resultSet, TSDataType dataType, 
int columnIndex)
+  {
+    switch (dataType) {
+      case INT32:
+        return resultSet.getInt(columnIndex);
+      case INT64:
+        return resultSet.getLong(columnIndex);
+      case FLOAT:
+        return resultSet.getFloat(columnIndex);
+      case DOUBLE:
+        return resultSet.getDouble(columnIndex);
+      case BOOLEAN:
+        return resultSet.getBoolean(columnIndex);
+      case TEXT:
+      case STRING:
+        return resultSet.getString(columnIndex);
+      case DATE:
+        return resultSet.getDate(columnIndex).toEpochDay();
+      case TIMESTAMP:
+        return resultSet.getLong(columnIndex);
+      case BLOB:
+        return 
Base64.getEncoder().encodeToString(resultSet.getBinary(columnIndex));
+      default:
+        throw InvalidInput.exception(
+            "TsFile column[%s] has an unsupported data type[%s]",
+            resultSet.getMetadata().getColumnName(columnIndex),
+            dataType
+        );
+    }
+  }
+}
diff --git 
a/extensions-contrib/tsfile-extensions/src/main/java/org/apache/druid/data/input/tsfile/TsFileInputFormat.java
 
b/extensions-contrib/tsfile-extensions/src/main/java/org/apache/druid/data/input/tsfile/TsFileInputFormat.java
new file mode 100644
index 00000000000..6be4119c2a6
--- /dev/null
+++ 
b/extensions-contrib/tsfile-extensions/src/main/java/org/apache/druid/data/input/tsfile/TsFileInputFormat.java
@@ -0,0 +1,148 @@
+/*
+ * 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.druid.data.input.tsfile;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import org.apache.druid.data.input.InputEntity;
+import org.apache.druid.data.input.InputEntityReader;
+import org.apache.druid.data.input.InputFormat;
+import org.apache.druid.data.input.InputRowSchema;
+import org.joda.time.Interval;
+
+import javax.annotation.Nullable;
+
+import java.io.File;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Reads Apache TsFile files into Druid. TsFile is a columnar file format for 
time series data, so a single file
+ * can hold multiple tables. Rows of each table are converted into {@link 
org.apache.druid.data.input.InputRow}s.
+ * <p>
+ * The timestamp of a TsFile row is returned as the {@code Time} column. The 
ingestion spec must therefore use
+ * {@code "timestampSpec": {"column": "Time", "format": "millis"}} (or any 
other numeric format).
+ */
+public class TsFileInputFormat implements InputFormat
+{
+  /** Name of the implicit time column of TsFile query results. */
+  public static final String TIME_COLUMN_NAME = "Time";
+
+  @Nullable
+  private final List<String> tables;
+  @Nullable
+  private final List<String> columns;
+  @Nullable
+  private final Interval interval;
+
+  @JsonCreator
+  public TsFileInputFormat(
+      @JsonProperty("tables") @Nullable List<String> tables,
+      @JsonProperty("columns") @Nullable List<String> columns,
+      @JsonProperty("interval") @Nullable Interval interval
+  )
+  {
+    this.tables = tables;
+    this.columns = columns;
+    this.interval = interval;
+  }
+
+  /**
+   * Tables to read. When null or empty, all tables in the file are read.
+   */
+  @Nullable
+  @JsonProperty("tables")
+  @JsonInclude(JsonInclude.Include.NON_NULL)
+  public List<String> getTables()
+  {
+    return tables;
+  }
+
+  /**
+   * Columns to read. When null or empty, all columns of each table are read.
+   */
+  @Nullable
+  @JsonProperty("columns")
+  @JsonInclude(JsonInclude.Include.NON_NULL)
+  public List<String> getColumns()
+  {
+    return columns;
+  }
+
+  /**
+   * Time range to read, in Druid's {@code [start, end)} semantics. When null, 
each file is read in full.
+   * The range is pushed down to the TsFile reader, which skips chunks whose 
time statistics fall outside of it.
+   */
+  @Nullable
+  @JsonProperty("interval")
+  @JsonInclude(JsonInclude.Include.NON_NULL)
+  public Interval getInterval()
+  {
+    return interval;
+  }
+
+  @Override
+  public boolean isSplittable()
+  {
+    return false;
+  }
+
+  @Override
+  public InputEntityReader createReader(
+      InputRowSchema inputRowSchema,
+      InputEntity source,
+      File temporaryDirectory
+  )
+  {
+    return new TsFileInputEntityReader(inputRowSchema, source, 
temporaryDirectory, tables, columns, interval);
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o) {
+      return true;
+    }
+    if (o == null || getClass() != o.getClass()) {
+      return false;
+    }
+    TsFileInputFormat that = (TsFileInputFormat) o;
+    return Objects.equals(tables, that.tables)
+           && Objects.equals(columns, that.columns)
+           && Objects.equals(interval, that.interval);
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Objects.hash(tables, columns, interval);
+  }
+
+  @Override
+  public String toString()
+  {
+    return "TsFileInputFormat{" +
+           "tables=" + tables +
+           ", columns=" + columns +
+           ", interval=" + interval +
+           '}';
+  }
+}
diff --git 
a/extensions-contrib/tsfile-extensions/src/main/resources/META-INF/services/org.apache.druid.initialization.DruidModule
 
b/extensions-contrib/tsfile-extensions/src/main/resources/META-INF/services/org.apache.druid.initialization.DruidModule
new file mode 100644
index 00000000000..1ca3048aab9
--- /dev/null
+++ 
b/extensions-contrib/tsfile-extensions/src/main/resources/META-INF/services/org.apache.druid.initialization.DruidModule
@@ -0,0 +1,16 @@
+# 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.
+
+org.apache.druid.data.input.tsfile.TsFileExtensionsModule
diff --git 
a/extensions-contrib/tsfile-extensions/src/test/java/org/apache/druid/data/input/tsfile/TsFileInputFormatTest.java
 
b/extensions-contrib/tsfile-extensions/src/test/java/org/apache/druid/data/input/tsfile/TsFileInputFormatTest.java
new file mode 100644
index 00000000000..ccbb1d71b4d
--- /dev/null
+++ 
b/extensions-contrib/tsfile-extensions/src/test/java/org/apache/druid/data/input/tsfile/TsFileInputFormatTest.java
@@ -0,0 +1,367 @@
+/*
+ * 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.druid.data.input.tsfile;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.druid.data.input.ColumnsFilter;
+import org.apache.druid.data.input.InputEntityReader;
+import org.apache.druid.data.input.InputFormat;
+import org.apache.druid.data.input.InputRow;
+import org.apache.druid.data.input.InputRowSchema;
+import org.apache.druid.data.input.impl.DimensionsSpec;
+import org.apache.druid.data.input.impl.FileEntity;
+import org.apache.druid.data.input.impl.TimestampSpec;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.jackson.DefaultObjectMapper;
+import org.apache.druid.java.util.common.DateTimes;
+import org.apache.druid.java.util.common.parsers.CloseableIterator;
+import org.apache.tsfile.exception.write.WriteProcessException;
+import org.joda.time.Interval;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class TsFileInputFormatTest
+{
+  @TempDir
+  public File temporaryDirectory;
+
+  private File sampleFile;
+
+  @BeforeEach
+  public void setUp() throws IOException, WriteProcessException
+  {
+    sampleFile = new File(temporaryDirectory, "sample.tsfile");
+    TsFileTestData.writeSampleFile(sampleFile);
+  }
+
+  @Test
+  public void testReadAllColumns() throws IOException
+  {
+    final InputRowSchema rowSchema = 
buildRowSchema(TsFileInputFormat.TIME_COLUMN_NAME, "millis");
+    final List<InputRow> rows = read(new TsFileInputFormat(null, null, null), 
rowSchema);
+
+    assertEquals(3, rows.size());
+
+    final InputRow first = rows.get(0);
+    assertEquals(1000L, first.getTimestampFromEpoch());
+    assertEquals(Collections.singletonList("beijing"), 
first.getDimension("region"));
+    assertEquals(Collections.singletonList("d1"), 
first.getDimension("device"));
+    assertEquals(21.5f, (Float) first.getRaw("temperature"), 0.0001f);
+    assertEquals(0.5d, (Double) first.getRaw("humidity"), 0.0001d);
+    assertEquals(Boolean.TRUE, first.getRaw("status"));
+
+    // humidity is missing for the second row.
+    final InputRow second = rows.get(1);
+    assertEquals(2000L, second.getTimestampFromEpoch());
+    assertNull(second.getRaw("humidity"));
+  }
+
+  @Test
+  public void testReadWithDefaultTimestampColumn() throws IOException
+  {
+    final InputRowSchema rowSchema = buildRowSchema("timestamp", "auto");
+    final List<InputRow> rows = read(new TsFileInputFormat(null, null, null), 
rowSchema);
+
+    assertEquals(3, rows.size());
+    assertEquals(1000L, rows.get(0).getTimestampFromEpoch());
+  }
+
+  @Test
+  public void testReadSelectedTableAndColumns() throws IOException
+  {
+    final InputRowSchema rowSchema = 
buildRowSchema(TsFileInputFormat.TIME_COLUMN_NAME, "millis");
+    final List<InputRow> rows = read(
+        new TsFileInputFormat(
+            Collections.singletonList(TsFileTestData.TABLE_NAME),
+            Arrays.asList("region", "temperature"),
+            null
+        ),
+        rowSchema
+    );
+
+    assertEquals(3, rows.size());
+    final InputRow first = rows.get(0);
+    assertEquals(Collections.singletonList("beijing"), 
first.getDimension("region"));
+    assertNull(first.getRaw("device"));
+    assertEquals(21.5f, (Float) first.getRaw("temperature"), 0.0001f);
+    assertNull(first.getRaw("humidity"));
+  }
+
+  @Test
+  public void testUnknownTable()
+  {
+    final InputRowSchema rowSchema = 
buildRowSchema(TsFileInputFormat.TIME_COLUMN_NAME, "millis");
+    final DruidException e = assertThrows(
+        DruidException.class,
+        () -> read(new TsFileInputFormat(Collections.singletonList("unknown"), 
null, null), rowSchema)
+    );
+    assertTrue(e.getMessage().contains("unknown"));
+  }
+
+  @Test
+  public void testUnknownColumn()
+  {
+    final InputRowSchema rowSchema = 
buildRowSchema(TsFileInputFormat.TIME_COLUMN_NAME, "millis");
+    final DruidException e = assertThrows(
+        DruidException.class,
+        () -> read(new TsFileInputFormat(null, 
Collections.singletonList("unknown"), null), rowSchema)
+    );
+    assertTrue(e.getMessage().contains("unknown"));
+  }
+
+  @Test
+  public void testIntervalPushdown() throws IOException
+  {
+    final InputRowSchema rowSchema = 
buildRowSchema(TsFileInputFormat.TIME_COLUMN_NAME, "millis");
+    final List<InputRow> rows = read(
+        new TsFileInputFormat(null, null, millis(1500L, 3501L)),
+        rowSchema
+    );
+
+    assertEquals(2, rows.size());
+    assertEquals(2000L, rows.get(0).getTimestampFromEpoch());
+    assertEquals(3000L, rows.get(1).getTimestampFromEpoch());
+  }
+
+  @Test
+  public void testIntervalIsEndExclusive() throws IOException
+  {
+    final InputRowSchema rowSchema = 
buildRowSchema(TsFileInputFormat.TIME_COLUMN_NAME, "millis");
+    final List<InputRow> rows = read(
+        new TsFileInputFormat(null, null, millis(1000L, 2000L)),
+        rowSchema
+    );
+
+    assertEquals(1, rows.size());
+    assertEquals(1000L, rows.get(0).getTimestampFromEpoch());
+  }
+
+  @Test
+  public void testEmptyIntervalReadsNothing() throws IOException
+  {
+    final InputRowSchema rowSchema = 
buildRowSchema(TsFileInputFormat.TIME_COLUMN_NAME, "millis");
+    final List<InputRow> rows = read(
+        new TsFileInputFormat(null, null, millis(2000L, 2000L)),
+        rowSchema
+    );
+
+    assertTrue(rows.isEmpty());
+  }
+
+  @Test
+  public void testTreeModelFileIsRejected() throws IOException, 
WriteProcessException
+  {
+    final File file = new File(temporaryDirectory, "tree-model.tsfile");
+    TsFileTestData.writeTreeModelFile(file);
+
+    final DruidException e = assertThrows(
+        DruidException.class,
+        () -> read(
+            file,
+            new TsFileInputFormat(null, null, null),
+            buildRowSchema(TsFileInputFormat.TIME_COLUMN_NAME, "millis")
+        )
+    );
+    assertTrue(e.getMessage().contains("no table schema"));
+  }
+
+  @Test
+  public void testTimestampAliasShadowsSameNamedColumn() throws IOException, 
WriteProcessException
+  {
+    final File file = new File(temporaryDirectory, "timestamp-column.tsfile");
+    TsFileTestData.writeFileWithTimestampColumn(file);
+
+    final List<InputRow> rows = read(
+        file,
+        new TsFileInputFormat(null, null, null),
+        buildRowSchema("timestamp", "millis")
+    );
+
+    assertEquals(1, rows.size());
+    // The TsFile time wins over the value of the TsFile column that happens 
to be named "timestamp".
+    assertEquals(1000L, rows.get(0).getTimestampFromEpoch());
+  }
+
+  @Test
+  public void testProjectionFromColumnsFilter() throws IOException
+  {
+    final InputRowSchema rowSchema = new InputRowSchema(
+        new TimestampSpec(TsFileInputFormat.TIME_COLUMN_NAME, "millis", null),
+        new 
DimensionsSpec(DimensionsSpec.getDefaultSchemas(Arrays.asList("region", 
"device"))),
+        ColumnsFilter.inclusionBased(new HashSet<>(Arrays.asList("region", 
"temperature")))
+    );
+    final List<InputRow> rows = read(new TsFileInputFormat(null, null, null), 
rowSchema);
+
+    assertEquals(3, rows.size());
+    final InputRow first = rows.get(0);
+    assertEquals(Collections.singletonList("beijing"), 
first.getDimension("region"));
+    assertEquals(21.5f, (Float) first.getRaw("temperature"), 0.0001f);
+    assertNull(first.getRaw("device"));
+    assertNull(first.getRaw("status"));
+  }
+
+  @Test
+  public void testProjectionFromFixedDimensions() throws IOException
+  {
+    // MSQ EXTERN passes ColumnsFilter.all() and puts the external table's 
column signature in the dimensions.
+    final InputRowSchema rowSchema = new InputRowSchema(
+        new TimestampSpec(TsFileInputFormat.TIME_COLUMN_NAME, "millis", null),
+        new 
DimensionsSpec(DimensionsSpec.getDefaultSchemas(Arrays.asList("region", 
"temperature"))),
+        ColumnsFilter.all()
+    );
+    final List<InputRow> rows = read(new TsFileInputFormat(null, null, null), 
rowSchema);
+
+    assertEquals(3, rows.size());
+    assertEquals(Collections.singletonList("beijing"), 
rows.get(0).getDimension("region"));
+    assertEquals(21.5f, (Float) rows.get(0).getRaw("temperature"), 0.0001f);
+    assertNull(rows.get(0).getRaw("device"));
+    assertNull(rows.get(0).getRaw("humidity"));
+    assertNull(rows.get(0).getRaw("status"));
+  }
+
+  @Test
+  public void testTagOnlyProjectionUsesRowCarrier() throws IOException
+  {
+    // TsFile's table reader returns no rows at all when only tag columns are 
selected, so the reader adds one
+    // measurement column as a row carrier without exposing it in the row.
+    final InputRowSchema rowSchema = new InputRowSchema(
+        new TimestampSpec(TsFileInputFormat.TIME_COLUMN_NAME, "millis", null),
+        new 
DimensionsSpec(DimensionsSpec.getDefaultSchemas(Collections.singletonList("region"))),
+        ColumnsFilter.all()
+    );
+    final List<InputRow> rows = read(new TsFileInputFormat(null, null, null), 
rowSchema);
+
+    assertEquals(3, rows.size());
+    assertEquals(Collections.singletonList("beijing"), 
rows.get(0).getDimension("region"));
+    assertNull(rows.get(0).getRaw("temperature"));
+    assertNull(rows.get(0).getRaw("device"));
+  }
+
+  @Test
+  public void testColumnsFilterWinsOverFixedDimensions() throws IOException
+  {
+    // A batch ingestion filter that happens to select every physical column 
must not be narrowed down to the
+    // dimensions: its extra entries are transform inputs or aggregator inputs.
+    final InputRowSchema rowSchema = new InputRowSchema(
+        new TimestampSpec(TsFileInputFormat.TIME_COLUMN_NAME, "millis", null),
+        new 
DimensionsSpec(DimensionsSpec.getDefaultSchemas(Collections.singletonList("region"))),
+        ColumnsFilter.inclusionBased(
+            new HashSet<>(Arrays.asList("region", "device", "temperature", 
"humidity", "status"))
+        )
+    );
+    final List<InputRow> rows = read(new TsFileInputFormat(null, null, null), 
rowSchema);
+
+    assertEquals(3, rows.size());
+    assertEquals(21.5f, (Float) rows.get(0).getRaw("temperature"), 0.0001f);
+    assertEquals(0.5d, (Double) rows.get(0).getRaw("humidity"), 0.0001d);
+    assertEquals(Boolean.TRUE, rows.get(0).getRaw("status"));
+  }
+
+  @Test
+  public void testSerde() throws IOException
+  {
+    final ObjectMapper mapper = new DefaultObjectMapper();
+    new 
TsFileExtensionsModule().getJacksonModules().forEach(mapper::registerModule);
+
+    final String json = 
"{\"type\":\"tsfile\",\"tables\":[\"sensor\"],\"columns\":[\"region\",\"temperature\"],"
+                        + 
"\"interval\":\"1970-01-01T00:00:00.000Z/1970-01-01T00:00:04.000Z\"}";
+    final TsFileInputFormat format = (TsFileInputFormat) 
mapper.readValue(json, InputFormat.class);
+    assertEquals(Collections.singletonList(TsFileTestData.TABLE_NAME), 
format.getTables());
+    assertEquals(Arrays.asList("region", "temperature"), format.getColumns());
+    assertEquals(millis(0L, 4000L), format.getInterval());
+    assertEquals(format, mapper.readValue(mapper.writeValueAsString(format), 
InputFormat.class));
+  }
+
+  @Test
+  public void testReadCommittedSampleFile() throws IOException
+  {
+    final File file = new File("examples/data/sensor.tsfile");
+    final List<InputRow> rows = read(
+        file,
+        new TsFileInputFormat(null, null, null),
+        buildRowSchema(TsFileInputFormat.TIME_COLUMN_NAME, "millis")
+    );
+
+    assertEquals(TsFileTestData.TIMESTAMPS.length, rows.size());
+    for (int i = 0; i < rows.size(); i++) {
+      final InputRow row = rows.get(i);
+      assertEquals(TsFileTestData.TIMESTAMPS[i], row.getTimestampFromEpoch());
+      assertEquals(Collections.singletonList(TsFileTestData.REGIONS[i]), 
row.getDimension("region"));
+      assertEquals(Collections.singletonList(TsFileTestData.DEVICES[i]), 
row.getDimension("device"));
+      assertEquals(TsFileTestData.TEMPERATURES[i], (Float) 
row.getRaw("temperature"), 0.0001f);
+      if (Double.isNaN(TsFileTestData.HUMIDITIES[i])) {
+        assertNull(row.getRaw("humidity"));
+      } else {
+        assertEquals(TsFileTestData.HUMIDITIES[i], (Double) 
row.getRaw("humidity"), 0.0001d);
+      }
+    }
+  }
+
+  private List<InputRow> read(TsFileInputFormat format, InputRowSchema 
rowSchema) throws IOException
+  {
+    return read(sampleFile, format, rowSchema);
+  }
+
+  private List<InputRow> read(File file, TsFileInputFormat format, 
InputRowSchema rowSchema) throws IOException
+  {
+    final InputEntityReader reader = format.createReader(rowSchema, new 
FileEntity(file), temporaryDirectory);
+    final List<InputRow> rows = new ArrayList<>();
+    try (CloseableIterator<InputRow> iterator = reader.read()) {
+      while (iterator.hasNext()) {
+        rows.add(iterator.next());
+      }
+    }
+    return rows;
+  }
+
+  private static Interval millis(long startMillis, long endMillis)
+  {
+    return new Interval(DateTimes.utc(startMillis), DateTimes.utc(endMillis));
+  }
+
+  /**
+   * Schema used by most tests: no column projection is applied, so the TsFile 
columns are read in full.
+   */
+  private static InputRowSchema buildRowSchema(String timestampColumn, String 
timestampFormat)
+  {
+    return new InputRowSchema(
+        new TimestampSpec(timestampColumn, timestampFormat, null),
+        DimensionsSpec.builder()
+                      
.setDimensions(DimensionsSpec.getDefaultSchemas(Arrays.asList("region", 
"device")))
+                      .useSchemaDiscovery(true)
+                      .build(),
+        ColumnsFilter.all()
+    );
+  }
+}
diff --git 
a/extensions-contrib/tsfile-extensions/src/test/java/org/apache/druid/data/input/tsfile/TsFileTestData.java
 
b/extensions-contrib/tsfile-extensions/src/test/java/org/apache/druid/data/input/tsfile/TsFileTestData.java
new file mode 100644
index 00000000000..4d4c774b1ee
--- /dev/null
+++ 
b/extensions-contrib/tsfile-extensions/src/test/java/org/apache/druid/data/input/tsfile/TsFileTestData.java
@@ -0,0 +1,149 @@
+/*
+ * 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.druid.data.input.tsfile;
+
+import org.apache.tsfile.enums.ColumnCategory;
+import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.exception.write.WriteProcessException;
+import org.apache.tsfile.file.metadata.ColumnSchemaBuilder;
+import org.apache.tsfile.file.metadata.TableSchema;
+import org.apache.tsfile.write.TsFileWriter;
+import org.apache.tsfile.write.record.TSRecord;
+import org.apache.tsfile.write.record.Tablet;
+import org.apache.tsfile.write.record.datapoint.IntDataPoint;
+import org.apache.tsfile.write.schema.MeasurementSchema;
+import org.apache.tsfile.write.v4.ITsFileWriter;
+import org.apache.tsfile.write.v4.TsFileWriterBuilder;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Arrays;
+
+/**
+ * Writes the sample TsFile used by {@link TsFileInputFormatTest} and by the 
example ingestion spec in
+ * {@code examples/}. Also serves as documentation for the TsFile layout the 
extension reads.
+ */
+public class TsFileTestData
+{
+  public static final String TABLE_NAME = "sensor";
+  public static final long[] TIMESTAMPS = {1000L, 2000L, 3000L};
+  public static final String[] REGIONS = {"beijing", "beijing", "shanghai"};
+  public static final String[] DEVICES = {"d1", "d2", "d3"};
+  public static final float[] TEMPERATURES = {21.5f, 22.5f, 23.5f};
+  /** NaN marks a row where the humidity is not written, which TsFile reads 
back as null. */
+  public static final double[] HUMIDITIES = {0.5d, Double.NaN, 0.7d};
+
+  private TsFileTestData()
+  {
+  }
+
+  /**
+   * Writes {@code examples/data/sensor.tsfile}, used by the example ingestion 
spec. Run from this module:
+   * <pre>
+   * mvn exec:java -Dexec.classpathScope=test \
+   *   -Dexec.mainClass=org.apache.druid.data.input.tsfile.TsFileTestData
+   * </pre>
+   */
+  public static void main(String[] args) throws IOException, 
WriteProcessException
+  {
+    final Path outputDir = Paths.get(args.length > 0 ? args[0] : 
"examples/data");
+    Files.createDirectories(outputDir);
+    final File file = outputDir.resolve("sensor.tsfile").toFile();
+    writeSampleFile(file);
+    System.out.println("Wrote " + file.getAbsolutePath());
+  }
+
+  public static void writeSampleFile(File file) throws IOException, 
WriteProcessException
+  {
+    final TableSchema tableSchema = new TableSchema(
+        TABLE_NAME,
+        Arrays.asList(
+            new 
ColumnSchemaBuilder().name("region").dataType(TSDataType.STRING).category(ColumnCategory.TAG).build(),
+            new 
ColumnSchemaBuilder().name("device").dataType(TSDataType.STRING).category(ColumnCategory.TAG).build(),
+            new 
ColumnSchemaBuilder().name("temperature").dataType(TSDataType.FLOAT).build(),
+            new 
ColumnSchemaBuilder().name("humidity").dataType(TSDataType.DOUBLE).build(),
+            new 
ColumnSchemaBuilder().name("status").dataType(TSDataType.BOOLEAN).build()
+        )
+    );
+
+    final Tablet tablet = new Tablet(
+        Arrays.asList("region", "device", "temperature", "humidity", "status"),
+        Arrays.asList(TSDataType.STRING, TSDataType.STRING, TSDataType.FLOAT, 
TSDataType.DOUBLE, TSDataType.BOOLEAN)
+    );
+
+    for (int i = 0; i < TIMESTAMPS.length; i++) {
+      tablet.addTimestamp(i, TIMESTAMPS[i]);
+      tablet.addValue(i, "region", REGIONS[i]);
+      tablet.addValue(i, "device", DEVICES[i]);
+      tablet.addValue(i, "temperature", TEMPERATURES[i]);
+      tablet.addValue(i, "status", i % 2 == 0);
+      if (!Double.isNaN(HUMIDITIES[i])) {
+        tablet.addValue(i, "humidity", HUMIDITIES[i]);
+      }
+    }
+
+    try (ITsFileWriter writer = new 
TsFileWriterBuilder().file(file).tableSchema(tableSchema).build()) {
+      writer.write(tablet);
+    }
+  }
+
+  /**
+   * Writes a legacy tree-model TsFile, which has no table schema in its 
footer and is not readable by the
+   * extension.
+   */
+  public static void writeTreeModelFile(File file) throws IOException, 
WriteProcessException
+  {
+    try (TsFileWriter writer = new TsFileWriter(file)) {
+      writer.registerTimeseries("root.sg.d1", new MeasurementSchema("s1", 
TSDataType.INT32));
+      writer.writeRecord(new TSRecord("root.sg.d1", 
TIMESTAMPS[0]).addTuple(new IntDataPoint("s1", 1)));
+      writer.flush();
+    }
+  }
+
+  /**
+   * Writes a table-model file that has a value column literally named {@code 
timestamp}, so that tests can check
+   * the timestamp alias wins over a same-named column.
+   */
+  public static void writeFileWithTimestampColumn(File file) throws 
IOException, WriteProcessException
+  {
+    final TableSchema tableSchema = new TableSchema(
+        TABLE_NAME,
+        Arrays.asList(
+            new 
ColumnSchemaBuilder().name("timestamp").dataType(TSDataType.INT64).build(),
+            new 
ColumnSchemaBuilder().name("temperature").dataType(TSDataType.FLOAT).build()
+        )
+    );
+
+    final Tablet tablet = new Tablet(
+        Arrays.asList("timestamp", "temperature"),
+        Arrays.asList(TSDataType.INT64, TSDataType.FLOAT)
+    );
+    tablet.addTimestamp(0, TIMESTAMPS[0]);
+    tablet.addValue(0, "timestamp", 999_999L);
+    tablet.addValue(0, "temperature", TEMPERATURES[0]);
+
+    try (ITsFileWriter writer = new 
TsFileWriterBuilder().file(file).tableSchema(tableSchema).build()) {
+      writer.write(tablet);
+    }
+  }
+}
diff --git a/licenses.yaml b/licenses.yaml
index 40cfad635cd..ea84a8066e1 100644
--- a/licenses.yaml
+++ b/licenses.yaml
@@ -7686,3 +7686,20 @@ module: extensions/druid-protobuf-extensions
 license_name: Apache License version 2.0
 libraries:
   - org.apache.kafka: kafka-clients
+
+---
+
+name: Apache TsFile
+license_category: binary
+version: 2.5.0
+module: extensions-contrib/druid-tsfile-extensions
+license_name: Apache License version 2.0
+libraries:
+  - org.apache.tsfile: tsfile
+  - org.apache.tsfile: common
+notice: |
+  TsFile: Java: TsFile
+  Copyright 2024 The Apache Software Foundation
+
+  This product includes software developed at
+  The Apache Software Foundation (http://www.apache.org/).
diff --git a/pom.xml b/pom.xml
index 58874723b55..c65c9201d67 100644
--- a/pom.xml
+++ b/pom.xml
@@ -85,6 +85,7 @@
         <avatica.version>1.29.0</avatica.version>
         <avro.version>1.12.2</avro.version>
         <parquet.version>1.18.1</parquet.version>
+        <tsfile.version>2.5.0</tsfile.version>
         <bytebuddy.version>1.18.15</bytebuddy.version>
         <!--
             The base calcite parser was copied into the project; when updating 
Calcite run dev/upgrade-calcite-parser to adopt upstream changes
@@ -270,6 +271,7 @@
         <module>extensions-contrib/spectator-histogram</module>
         <module>extensions-contrib/rabbit-stream-indexing-service</module>
         <module>extensions-contrib/druid-ranger-security</module>
+        <module>extensions-contrib/tsfile-extensions</module>
         <!-- distribution packaging -->
         <module>distribution</module>
         <!-- more test stuff -->
@@ -593,6 +595,11 @@
                 <artifactId>zookeeper-jute</artifactId>
                 <version>${zookeeper.version}</version>
             </dependency>
+            <dependency>
+                <groupId>org.apache.tsfile</groupId>
+                <artifactId>tsfile</artifactId>
+                <version>${tsfile.version}</version>
+            </dependency>
             <dependency>
                 <groupId>org.apache.curator</groupId>
                 <artifactId>curator-client</artifactId>
@@ -2344,6 +2351,7 @@
                                 <exclude>**/*.json</exclude>
                                 <exclude>**/*.parq</exclude>
                                 <exclude>**/*.parquet</exclude>
+                                <exclude>**/*.tsfile</exclude>
                                 <exclude>**/*.pmd</exclude> <!-- Artifact of 
maven-pmd-plugin -->
                                 <exclude>**/*.pmdruleset.xml</exclude> <!-- 
Artifact of maven-pmd-plugin -->
                                 <exclude>**/docker/schema-registry/*</exclude>
diff --git a/website/.spelling b/website/.spelling
index 6b5a8c47d63..3ce16dc0927 100644
--- a/website/.spelling
+++ b/website/.spelling
@@ -2711,3 +2711,13 @@ getters
 savepoints
 Scrollable
 updatable
+
+- ../docs/development/extensions-contrib/tsfile.md
+TsFile
+IoTDB
+INT32
+INT64
+
+- ../docs/configuration/extensions.md
+druid-tsfile-extensions
+TsFile


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to