You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tsfile.apache.org by co...@apache.org on 2026/10/10 11:28:41 UTC

(tsfile) 06/08: [Python] Trust dataset indexes and expose bounded read caches (#1004)

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

ColinLeeo pushed a commit to branch rc/2.5.0
in repository https://gitbox.apache.org/repos/asf/tsfile.git

commit c2bbfeb4371ec1f8eb9d764e6f79acb7bff9878d
Author: Colin Lee <10...@users.noreply.github.com>
AuthorDate: Sat Oct 10 16:57:35 2026 +0800

    [Python] Trust dataset indexes and expose bounded read caches (#1004)
---
 cpp/src/cwrapper/tsfile_cwrapper.cc       |   7 +-
 cpp/src/cwrapper/tsfile_cwrapper.h        |  10 +-
 cpp/src/file/tsfile_io_reader.cc          |  11 +-
 cpp/src/reader/prepared_series.h          |   5 +-
 python/README-zh.md                       |  61 ++++
 python/README.md                          |  67 ++++
 python/tests/test_dataset_index.py        |  98 +++++-
 python/tests/test_dataset_read_options.py | 524 ++++++++++++++++++++++++++++++
 python/tsfile/dataset/_config.py          |  66 ++++
 python/tsfile/dataset/dataframe.py        |  45 ++-
 python/tsfile/dataset/index.py            |  19 +-
 python/tsfile/dataset/runtime.py          | 312 ++++++++++++++----
 python/tsfile/tsfile_cpp.pxd              |   5 +-
 python/tsfile/tsfile_py_cpp.pxd           |   4 +-
 python/tsfile/tsfile_py_cpp.pyx           |  10 +-
 python/tsfile/tsfile_reader.pyx           |   9 +-
 16 files changed, 1139 insertions(+), 114 deletions(-)

diff --git a/cpp/src/cwrapper/tsfile_cwrapper.cc b/cpp/src/cwrapper/tsfile_cwrapper.cc
index ff70990ca..c0fd7f842 100644
--- a/cpp/src/cwrapper/tsfile_cwrapper.cc
+++ b/cpp/src/cwrapper/tsfile_cwrapper.cc
@@ -567,7 +567,7 @@ ERRNO tsfile_writer_write_arrow(TsFileWriter writer, ArrowArray* array,
 // Query
 
 PreparedSeriesHandle tsfile_reader_prepare_series(
-    TsFileReader reader, const TsFilePreparedLocator* locator,
+    TsFileReader reader, const TsFilePreparedLocator* locator, bool trust_index,
     ERRNO* err_code) {
     if (err_code == nullptr) {
         return nullptr;
@@ -581,6 +581,7 @@ PreparedSeriesHandle tsfile_reader_prepare_series(
     generation.file_id = locator->file_id;
     generation.file_size = locator->file_size;
     generation.file_fingerprint = locator->file_fingerprint;
+    generation.trust_index = trust_index;
     storage::PreparedLocator native_locator;
     native_locator.locator_id = locator->locator_id;
     native_locator.layout = locator->layout;
@@ -605,7 +606,8 @@ PreparedSeriesHandle tsfile_reader_prepare_series(
 
 PreparedSeriesHandle tsfile_reader_prepare_series_with_time_owner(
     TsFileReader reader, const TsFilePreparedLocator* locator,
-    PreparedSeriesHandle aligned_time_owner, ERRNO* err_code) {
+    PreparedSeriesHandle aligned_time_owner, bool trust_index,
+    ERRNO* err_code) {
     if (err_code == nullptr) {
         return nullptr;
     }
@@ -620,6 +622,7 @@ PreparedSeriesHandle tsfile_reader_prepare_series_with_time_owner(
     generation.file_id = locator->file_id;
     generation.file_size = locator->file_size;
     generation.file_fingerprint = locator->file_fingerprint;
+    generation.trust_index = trust_index;
     storage::PreparedLocator native_locator;
     native_locator.locator_id = locator->locator_id;
     native_locator.layout = locator->layout;
diff --git a/cpp/src/cwrapper/tsfile_cwrapper.h b/cpp/src/cwrapper/tsfile_cwrapper.h
index 4a87db90c..ee1158f61 100644
--- a/cpp/src/cwrapper/tsfile_cwrapper.h
+++ b/cpp/src/cwrapper/tsfile_cwrapper.h
@@ -729,15 +729,19 @@ ERRNO tsfile_writer_write_arrow(TsFileWriter writer, ArrowArray* array,
 
 /*-------------------TsFile reader query data------------------ */
 
-/** Deserialize one exact Dataset Index locator into a reusable series. */
+/** Deserialize one exact Dataset Index locator into a reusable series.
+ * trust_index skips source generation checks; locator bounds remain checked.
+ */
 PreparedSeriesHandle tsfile_reader_prepare_series(
-    TsFileReader reader, const TsFilePreparedLocator* locator, ERRNO* err_code);
+    TsFileReader reader, const TsFilePreparedLocator* locator, bool trust_index,
+    ERRNO* err_code);
 
 /** Prepare an aligned value locator by sharing an existing parsed time index.
+ * trust_index skips source generation checks; locator bounds remain checked.
  */
 PreparedSeriesHandle tsfile_reader_prepare_series_with_time_owner(
     TsFileReader reader, const TsFilePreparedLocator* locator,
-    PreparedSeriesHandle aligned_time_owner, ERRNO* err_code);
+    PreparedSeriesHandle aligned_time_owner, bool trust_index, ERRNO* err_code);
 
 /** Release a prepared handle. Existing result sets remain independently owned.
  */
diff --git a/cpp/src/file/tsfile_io_reader.cc b/cpp/src/file/tsfile_io_reader.cc
index 49f5fd9f8..0db0eb228 100644
--- a/cpp/src/file/tsfile_io_reader.cc
+++ b/cpp/src/file/tsfile_io_reader.cc
@@ -142,13 +142,16 @@ int TsFileIOReader::prepare_series(
     uint64_t actual_size = 0;
     uint64_t actual_fingerprint = 0;
     if (read_file_ == nullptr || generation.file_size == 0 ||
-        read_file_->generation(actual_size, actual_fingerprint) != E_OK ||
-        actual_size != generation.file_size ||
-        (generation.file_fingerprint != 0 &&
-         actual_fingerprint != generation.file_fingerprint) ||
         locator.value_metadata_length == 0 || locator.layout > 1) {
         return E_INVALID_ARG;
     }
+    if (!generation.trust_index &&
+        (read_file_->generation(actual_size, actual_fingerprint) != E_OK ||
+         actual_size != generation.file_size ||
+         (generation.file_fingerprint != 0 &&
+          actual_fingerprint != generation.file_fingerprint))) {
+        return E_INVALID_ARG;
+    }
     std::shared_ptr<PreparedSeries> candidate =
         std::make_shared<PreparedSeries>(generation, locator);
     TimeseriesIndex* value_index = nullptr;
diff --git a/cpp/src/reader/prepared_series.h b/cpp/src/reader/prepared_series.h
index ce647966b..5df4ebeca 100644
--- a/cpp/src/reader/prepared_series.h
+++ b/cpp/src/reader/prepared_series.h
@@ -34,12 +34,15 @@ struct FileGeneration {
     uint32_t file_id;
     uint64_t file_size;
     uint64_t file_fingerprint;
+    // A sealed Dataset may skip checking the source file generation.
+    bool trust_index;
 
     FileGeneration()
         : mapped_index_identity(0),
           file_id(0),
           file_size(0),
-          file_fingerprint(0) {}
+          file_fingerprint(0),
+          trust_index(false) {}
 };
 
 struct PreparedLocator {
diff --git a/python/README-zh.md b/python/README-zh.md
index 9d322370d..59d2eb0f1 100644
--- a/python/README-zh.md
+++ b/python/README-zh.md
@@ -68,6 +68,67 @@ mvn -P with-cpp,with-python clean verify
 python setup.py build_ext --inplace
 ```
 
+## Dataset 索引
+
+通过 `use_index=True` 启用持久化 Dataset 索引。第一次打开时构建索引,之后
+复用已有索引。`trust_index=True` 是默认值,假设索引对应的 TsFile 不再变更,
+因此加载索引和查询时跳过源文件大小、修改时间和文件代次检查。
+索引格式与 locator 边界始终会检查。
+
+```python
+from tsfile import TsFileDataFrame
+
+with TsFileDataFrame("dataset/", use_index=True) as dataset:
+    values = dataset[0][:]
+
+# 加载索引和获取查询 reader 时检查源文件是否发生变更。
+with TsFileDataFrame("dataset/", use_index=True, trust_index=False) as dataset:
+    values = dataset[0][:]
+```
+
+设置 `trust_index=False` 时,打开 dataset 会重建已过期的索引;获取查询 reader
+时检测到文件变更则报错。`trust_index` 仅限关键字传入,在默认的
+`use_index=False` 模式下没有作用。
+
+## Dataset 读取资源上限
+
+`TsFileDataFrame` 在 `use_index=True` 时支持以下仅限关键字的配置参数:
+
+```python
+from tsfile import TsFileDataFrame
+
+with TsFileDataFrame(
+    ["part1.tsfile", "part2.tsfile"],
+    use_index=True,
+    max_prepared_series=32,
+    descriptor_cache_size=32,
+    max_open_files=16,
+    query_workers=4,
+) as frame:
+    values = frame[0][:]
+```
+
+| 参数 | 环境变量 | 默认值 | 有效取值 |
+|------|----------|--------|----------|
+| `max_prepared_series` | `TSFILE_DATAFRAME_MAX_PREPARED_SERIES` | 4096 | 非负整数 |
+| `descriptor_cache_size` | `TSFILE_DATAFRAME_DESCRIPTOR_CACHE_SIZE` | 4096 | 非负整数 |
+| `max_open_files` | `TSFILE_DATAFRAME_MAX_OPEN_FILES` | 16 | 正整数 |
+| `query_workers` | `TSFILE_DATAFRAME_QUERY_WORKERS` | `min(4, os.cpu_count() or 1)` | 正整数 |
+| `query_parallel_min_rows` | `TSFILE_DATAFRAME_QUERY_PARALLEL_MIN_ROWS` | 8192 | 正整数 |
+
+显式参数优先于对应的环境变量。参数为 `None` 时读取环境变量;变量未设置时
+使用内置默认值。配置在构造 frame 时一次性确定,之后修改环境变量不会影响
+已有 frame。子集共享父 frame 的 runtime 和配置。非法值在打开数据文件或索引前
+报错。显式传入这些参数要求 `use_index=True`;默认的无索引模式不读取这些环境变量。
+
+预备序列缓存保留已解析的原生序列元数据,按 LRU 淘汰空闲条目并释放原生句柄。
+查询正在使用的条目,以及其他序列仍依赖的共享时间元数据,会保持有效,因而
+并发查询期间条目数可能暂时超过上限,查询结束后再回收。这是每个 runtime 的
+条目数量上限,不是整个进程的内存上限;设置为 `0` 表示使用结束后不保留缓存。
+`descriptor_cache_size` 分别限制名称描述符缓存和序列路由缓存,`0` 禁用二者。
+`max_open_files` 限制打开的 reader 数量,`query_workers=1` 表示串行执行查询组。
+多个 runtime 或工作进程分别维护各自的上限。
+
 ## 文件级 Properties
 
 `TsFileWriter` 和 `TsFileTableWriter` 可以在打开期间写入二进制 property。
diff --git a/python/README.md b/python/README.md
index 22e063166..60607fe7d 100644
--- a/python/README.md
+++ b/python/README.md
@@ -61,6 +61,50 @@ Build by python command:
 python setup.py build_ext --inplace
 ```
 
+## Dataset read resource limits
+
+`TsFileDataFrame` accepts keyword-only resource options when `use_index=True`:
+
+```python
+from tsfile import TsFileDataFrame
+
+with TsFileDataFrame(
+    ["part1.tsfile", "part2.tsfile"],
+    use_index=True,
+    max_prepared_series=32,
+    descriptor_cache_size=32,
+    max_open_files=16,
+    query_workers=4,
+) as frame:
+    values = frame[0][:]
+```
+
+| Parameter | Environment variable | Default | Allowed values |
+|-----------|----------------------|---------|----------------|
+| `max_prepared_series` | `TSFILE_DATAFRAME_MAX_PREPARED_SERIES` | 4096 | Integer >= 0 |
+| `descriptor_cache_size` | `TSFILE_DATAFRAME_DESCRIPTOR_CACHE_SIZE` | 4096 | Integer >= 0 |
+| `max_open_files` | `TSFILE_DATAFRAME_MAX_OPEN_FILES` | 16 | Integer >= 1 |
+| `query_workers` | `TSFILE_DATAFRAME_QUERY_WORKERS` | `min(4, os.cpu_count() or 1)` | Integer >= 1 |
+| `query_parallel_min_rows` | `TSFILE_DATAFRAME_QUERY_PARALLEL_MIN_ROWS` | 8192 | Integer >= 1 |
+
+An explicit parameter takes precedence over its environment variable. `None`
+uses the environment variable, or the built-in default if it is unset. Options
+are resolved once when constructing the frame; later environment changes do not
+affect it. Subsets share their parent's runtime and limits. Invalid values raise
+an error before dataset files or indexes are opened. Explicit resource options
+require `use_index=True`; the default non-indexed mode ignores these environment
+variables.
+
+The prepared-series cache retains parsed native series metadata. It evicts the
+least recently used idle entries and releases their native handles. Active
+queries and dependent series pin shared time metadata, so the entry count can
+temporarily exceed the limit until those queries finish. This is an entry-count
+limit per runtime, not a process-wide memory limit. A value of `0` disables
+retention after use. `descriptor_cache_size` bounds each of the named-descriptor
+and series-route caches; `0` disables both. `max_open_files` bounds open readers,
+and `query_workers=1` runs query groups serially. Multiple runtimes or worker
+processes have separate limits.
+
 ## File-level properties
 
 `TsFileWriter` and `TsFileTableWriter` accept binary properties while they are
@@ -78,6 +122,29 @@ with TsFileReader("example.tsfile") as reader:
 Values do not carry a data type; use an explicit portable encoding when storing
 numbers or structures.
 
+## Dataset indexes
+
+Enable the persistent dataset index with `use_index=True`. The first open
+builds the index; subsequent opens reuse it. By default, `trust_index=True`
+assumes the indexed TsFiles will not change and skips source file size,
+modification time, and generation checks during index loading and queries.
+Index format and locator bounds are always checked.
+
+```python
+from tsfile import TsFileDataFrame
+
+with TsFileDataFrame("dataset/", use_index=True) as dataset:
+    series = dataset[0]
+
+# Check source generations when loading the index and acquiring readers.
+with TsFileDataFrame("dataset/", use_index=True, trust_index=False) as dataset:
+    series = dataset[0]
+```
+
+With `trust_index=False`, a stale index is rebuilt when the dataset opens;
+changes detected while acquiring a query reader raise an error. `trust_index`
+is keyword-only and has no effect when `use_index=False`, which remains the default.
+
 ## Local File Read Backend
 
 Python readers inherit the process-wide backend setting when they open a file.
diff --git a/python/tests/test_dataset_index.py b/python/tests/test_dataset_index.py
index 3d768540e..750fdb119 100644
--- a/python/tests/test_dataset_index.py
+++ b/python/tests/test_dataset_index.py
@@ -640,6 +640,67 @@ def test_hot_construction_maps_index_without_opening_readers(tmp_path, monkeypat
         series.close()
 
 
+@pytest.mark.parametrize("capacity", [0, 1, 2])
+def test_trust_index_defaults_to_skipping_generation_checks(
+    tmp_path, monkeypatch, capacity
+):
+    source = tmp_path / "part.tsfile"
+    _write_runtime_file(source, 0)
+    with TsFileDataFrame(str(source), show_progress=False, use_index=True):
+        pass
+
+    stat = os.stat(source)
+    os.utime(source, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000))
+
+    def fail_check(*_args, **_kwargs):
+        raise AssertionError("trusted indexes must not check source generations")
+
+    monkeypatch.setattr(index_module, "file_fingerprint", fail_check)
+    monkeypatch.setattr(runtime_module, "file_fingerprint", fail_check)
+    monkeypatch.setattr(
+        runtime_module._ReaderSession, "_validate_generation", fail_check
+    )
+    monkeypatch.setattr("tsfile.dataset.reader.TsFileSeriesReader", fail_check)
+    with TsFileDataFrame(
+        str(source),
+        show_progress=False,
+        use_index=True,
+        max_prepared_series=capacity,
+        descriptor_cache_size=capacity,
+    ) as dataframe:
+        with dataframe[:1] as subset:
+            assert subset._trust_index is True
+            np.testing.assert_array_equal(subset[0][:], np.array([0.0, 1.0]))
+        np.testing.assert_array_equal(dataframe[0][:], np.array([0.0, 1.0]))
+        assert dataframe._runtime.prepared.size <= capacity
+
+
+def test_trust_index_false_rebuilds_a_stale_index(tmp_path):
+    source = tmp_path / "part.tsfile"
+    _write_runtime_file(source, 0)
+    with TsFileDataFrame(str(source), show_progress=False, use_index=True) as dataframe:
+        index_path = dataframe._runtime.index.path
+        fingerprint = dataframe._runtime.index.record(TSFILE_RECORD, 0)[3]
+
+    stat = os.stat(source)
+    os.utime(source, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000))
+    assert index_module.index_matches_paths(index_path, [str(source)])
+    assert not index_module.index_matches_paths(
+        index_path, [str(source)], trust_index=False
+    )
+    with TsFileDataFrame(
+        str(source), show_progress=False, use_index=True, trust_index=False
+    ) as dataframe:
+        assert dataframe._runtime.index.record(TSFILE_RECORD, 0)[3] != fingerprint
+        np.testing.assert_array_equal(dataframe[0][:], np.array([0.0, 1.0]))
+
+
+@pytest.mark.parametrize("trust_index", [None, 1, "true"])
+def test_trust_index_requires_a_bool(tmp_path, trust_index):
+    with pytest.raises(TypeError, match="trust_index must be a bool"):
+        TsFileDataFrame(str(tmp_path / "missing.tsfile"), trust_index=trust_index)
+
+
 def test_dataframe_does_not_use_or_create_index_by_default(tmp_path):
     source = tmp_path / "part.tsfile"
     _write_runtime_file(source, 0)
@@ -797,9 +858,9 @@ def test_series_path_from_another_index_falls_back_to_its_name(tmp_path, monkeyp
 def test_runtime_descriptor_cache_evicts_least_recent_name(tmp_path, monkeypatch):
     source = tmp_path / "devices.tsfile"
     _write_runtime_devices_file(source)
-    monkeypatch.setattr(runtime_module, "_SERIES_DESCRIPTOR_CACHE_SIZE", 2)
-
-    with TsFileDataFrame(str(source), show_progress=False, use_index=True) as dataframe:
+    with TsFileDataFrame(
+        str(source), show_progress=False, use_index=True, descriptor_cache_size=2
+    ) as dataframe:
         names = [str(name) for name in dataframe.list_timeseries()]
         find_device_calls = 0
         original_find_device = dataframe._runtime.index.find_device_id
@@ -925,8 +986,9 @@ def test_prepared_query_reads_nullable_offset_window_in_arrow_batches(tmp_path):
         runtime = dataframe._runtime
         series = runtime.index.record(LOGICAL_SERIES, 0)
         span = runtime.index.record(SERIES_FILE_SPAN, series[2])
-        with runtime.readers.acquire(0) as reader:
-            prepared = runtime.prepared.get(0, span[2], reader)
+        with runtime.readers.acquire(0) as reader, runtime.prepared.acquire(
+            0, span[2], reader
+        ) as prepared:
             with reader.query_prepared(prepared, offset=1, limit=7) as result:
                 batches = []
                 while True:
@@ -951,7 +1013,8 @@ def test_prepared_query_reads_nullable_offset_window_in_arrow_batches(tmp_path):
     )
 
 
-def test_prepared_locator_rejects_stale_generation_and_bad_range(tmp_path):
+@pytest.mark.parametrize("trust_index", [True, False])
+def test_prepared_locator_generation_checks_and_bounds(tmp_path, trust_index):
     source = tmp_path / "part.tsfile"
     _write_runtime_file(source, 0)
     with TsFileDataFrame(str(source), show_progress=False, use_index=True) as dataframe:
@@ -962,27 +1025,38 @@ def test_prepared_locator_rejects_stale_generation_and_bad_range(tmp_path):
         with runtime.readers.acquire(0) as reader:
             stale = list(locator)
             stale[3] ^= 1
-            with pytest.raises(Exception, match="prepare Dataset Index locator"):
-                reader.prepare_series(stale)
+            if trust_index:
+                reader.prepare_series(stale, trust_index=True).close()
+            else:
+                with pytest.raises(Exception, match="prepare Dataset Index locator"):
+                    reader.prepare_series(stale)
 
             out_of_range = list(locator)
             out_of_range[7] = os.path.getsize(source) + 1
             with pytest.raises(Exception, match="prepare Dataset Index locator"):
-                reader.prepare_series(out_of_range)
+                reader.prepare_series(out_of_range, trust_index=trust_index)
 
 
-def test_reader_session_revalidates_generation_when_reused(tmp_path):
+@pytest.mark.parametrize("trust_index", [True, False])
+def test_reader_session_generation_checks_follow_trust_index(tmp_path, trust_index):
     source = tmp_path / "part.tsfile"
     _write_runtime_file(source, 0)
-    with TsFileDataFrame(str(source), show_progress=False, use_index=True) as dataframe:
+    with TsFileDataFrame(
+        str(source), show_progress=False, use_index=True, trust_index=trust_index
+    ) as dataframe:
         pool = dataframe._runtime.readers
         with pool.acquire(0):
             pass
         stat = os.stat(source)
         os.utime(source, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000))
-        with pytest.raises(RuntimeError, match="generation changed"):
+        if trust_index:
             with pool.acquire(0):
                 pass
+            np.testing.assert_array_equal(dataframe[0][:], np.array([0.0, 1.0]))
+        else:
+            with pytest.raises(RuntimeError, match="generation changed"):
+                with pool.acquire(0):
+                    pass
 
 
 def test_runtime_lease_close_waits_for_query_lease(tmp_path):
diff --git a/python/tests/test_dataset_read_options.py b/python/tests/test_dataset_read_options.py
new file mode 100644
index 000000000..8872f8cc8
--- /dev/null
+++ b/python/tests/test_dataset_read_options.py
@@ -0,0 +1,524 @@
+# 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.
+
+from collections import OrderedDict
+from concurrent.futures import ThreadPoolExecutor
+from contextlib import ExitStack
+import threading
+from types import SimpleNamespace
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from tsfile import (
+    ColumnCategory,
+    ColumnSchema,
+    TableSchema,
+    TSDataType,
+    TsFileDataFrame,
+    TsFileTableWriter,
+)
+from tsfile.dataset.runtime import PreparedSeriesCache
+
+_OPTIONS = (
+    "max_prepared_series",
+    "descriptor_cache_size",
+    "max_open_files",
+    "query_workers",
+    "query_parallel_min_rows",
+)
+
+
+@pytest.fixture
+def dataset_file(tmp_path):
+    path = tmp_path / "devices.tsfile"
+    schema = TableSchema(
+        "weather",
+        [
+            ColumnSchema("device", TSDataType.STRING, ColumnCategory.TAG),
+            ColumnSchema("value", TSDataType.DOUBLE, ColumnCategory.FIELD),
+            ColumnSchema("other", TSDataType.DOUBLE, ColumnCategory.FIELD),
+        ],
+    )
+    with TsFileTableWriter(str(path), schema) as writer:
+        writer.write_dataframe(
+            pd.DataFrame(
+                {
+                    "time": [0, 1, 0, 1, 0, 1],
+                    "device": ["d0", "d0", "d1", "d1", "d2", "d2"],
+                    "value": [0.0, 1.0, 10.0, 11.0, 20.0, 21.0],
+                    "other": [100.0, np.nan, 110.0, 111.0, 120.0, 121.0],
+                }
+            )
+        )
+    return str(path)
+
+
+def _effective_options(dataframe):
+    runtime = dataframe._runtime
+    return (
+        runtime.prepared.max_entries,
+        runtime.catalog._descriptor_cache_size,
+        runtime.readers.max_open_files,
+        runtime.query_workers,
+        runtime.query_parallel_min_rows,
+    )
+
+
+def test_dataframe_options_override_environment(dataset_file, monkeypatch):
+    for option in _OPTIONS:
+        monkeypatch.setenv("TSFILE_DATAFRAME_" + option.upper(), "invalid")
+    options = dict(zip(_OPTIONS, (2, 3, 4, 1, 5)))
+    with TsFileDataFrame(
+        dataset_file, show_progress=False, use_index=True, **options
+    ) as dataframe:
+        assert _effective_options(dataframe) == (2, 3, 4, 1, 5)
+        with dataframe[[0, 1]] as subset:
+            assert subset._runtime is dataframe._runtime
+            assert _effective_options(subset) == _effective_options(dataframe)
+
+
+def test_dataframe_snapshots_environment_for_each_runtime(dataset_file, monkeypatch):
+    for option in _OPTIONS:
+        monkeypatch.setenv("TSFILE_DATAFRAME_" + option.upper(), "2")
+    with TsFileDataFrame(dataset_file, show_progress=False, use_index=True) as first:
+        for option in _OPTIONS:
+            monkeypatch.setenv("TSFILE_DATAFRAME_" + option.upper(), "1")
+        with TsFileDataFrame(
+            dataset_file, show_progress=False, use_index=True
+        ) as second:
+            assert _effective_options(first) == (2, 2, 2, 2, 2)
+            assert _effective_options(second) == (1, 1, 1, 1, 1)
+
+
+def test_dataframe_read_options_defaults(dataset_file, monkeypatch):
+    for option in _OPTIONS:
+        monkeypatch.delenv("TSFILE_DATAFRAME_" + option.upper(), raising=False)
+    monkeypatch.setattr("os.cpu_count", lambda: 2)
+    with TsFileDataFrame(
+        dataset_file, show_progress=False, use_index=True
+    ) as dataframe:
+        assert _effective_options(dataframe) == (4096, 4096, 16, 2, 8192)
+
+
+@pytest.mark.parametrize("capacity", [0, 1, 2])
+@pytest.mark.parametrize("trust_index", [True, False])
+def test_small_caches_preserve_single_and_aligned_reads(
+    dataset_file, capacity, trust_index
+):
+    with TsFileDataFrame(
+        dataset_file,
+        show_progress=False,
+        use_index=True,
+        trust_index=trust_index,
+        max_prepared_series=capacity,
+        descriptor_cache_size=capacity,
+        query_workers=2,
+        query_parallel_min_rows=1,
+    ) as dataframe:
+        names = [str(name) for name in dataframe.list_timeseries()]
+        baseline = dataframe.loc[0:1, names].values.copy()
+        expected = []
+        for name in names:
+            device = int(name.split(".")[1][1:])
+            values = np.array([device * 10.0, device * 10.0 + 1])
+            if name.endswith(".other"):
+                values += 100
+                if device == 0:
+                    values[1] = np.nan
+            expected.append(values)
+        np.testing.assert_allclose(baseline, np.column_stack(expected), equal_nan=True)
+        for _ in range(2):
+            for column, name in enumerate(names):
+                with dataframe[name] as series:
+                    np.testing.assert_allclose(
+                        series[:], baseline[:, column], equal_nan=True
+                    )
+                assert dataframe._runtime.prepared.size <= capacity
+                assert len(dataframe._index._descriptor_cache) <= capacity
+                assert len(dataframe._index.series_shards._cache) <= capacity
+            np.testing.assert_allclose(
+                dataframe.loc[0:1, names].values, baseline, equal_nan=True
+            )
+            assert dataframe._runtime.prepared.size <= capacity
+
+
+@pytest.mark.parametrize("option", _OPTIONS)
+@pytest.mark.parametrize("value", ["invalid", "-1", "1.5"])
+def test_invalid_environment_is_rejected_before_reading_files(
+    monkeypatch, option, value
+):
+    env_name = "TSFILE_DATAFRAME_" + option.upper()
+    monkeypatch.setenv(env_name, value)
+    with pytest.raises(ValueError, match=env_name):
+        TsFileDataFrame("missing.tsfile", use_index=True)
+
+
+@pytest.mark.parametrize("option", _OPTIONS[2:])
+def test_zero_requires_positive_read_limits(option):
+    with pytest.raises(ValueError, match=option):
+        TsFileDataFrame("missing.tsfile", use_index=True, **{option: 0})
+
+
+class _Prepared:
+    def __init__(self, locator_id, time_owner=None):
+        self.locator_id = locator_id
+        self.time_owner = time_owner
+        self.closed = False
+
+    def close(self):
+        self.closed = True
+
+
+class _Reader:
+    def __init__(self):
+        self.prepared = []
+
+    def prepare_series(self, locator, time_owner=None, trust_index=False):
+        assert time_owner is None or not time_owner.closed
+        prepared = _Prepared(locator, time_owner)
+        self.prepared.append(prepared)
+        return prepared
+
+
+def _cache(monkeypatch, capacity):
+    cache = PreparedSeriesCache(SimpleNamespace(), max_entries=capacity)
+    monkeypatch.setattr(cache, "_locator_tuple", lambda _file, locator: locator)
+    return cache
+
+
+def test_prepared_cache_evicts_lru_and_reprepares(monkeypatch):
+    cache = _cache(monkeypatch, 2)
+    reader = _Reader()
+    with cache.acquire(0, 0, reader) as first:
+        pass
+    with cache.acquire(0, 1, reader) as second:
+        pass
+    with cache.acquire(0, 0, reader) as reused:
+        assert reused is first
+    with cache.acquire(0, 2, reader):
+        assert second.closed
+        assert not first.closed
+    assert cache.size == 2
+    with cache.acquire(0, 1, reader) as rebuilt:
+        assert rebuilt is not second
+        assert not rebuilt.closed
+    cache.close()
+    assert all(item.closed for item in reader.prepared)
+
+
+def test_prepared_cache_preserves_access_order_when_leases_finish_out_of_order(
+    monkeypatch,
+):
+    cache = _cache(monkeypatch, 2)
+    reader = _Reader()
+    with cache.acquire(0, 0, reader) as oldest:
+        with cache.acquire(0, 1, reader) as newer:
+            pass
+    with cache.acquire(0, 2, reader):
+        assert oldest.closed
+        assert not newer.closed
+    cache.close()
+
+
+def test_prepared_cache_restores_unpinned_owner_in_access_order(monkeypatch):
+    cache = _cache(monkeypatch, 3)
+    reader = _Reader()
+    with cache.acquire(0, 0, reader) as owner:
+        with cache.acquire(0, 1, reader, time_owner=owner) as dependent:
+            pass
+    with cache.acquire(0, 2, reader) as newer:
+        pass
+    with cache.acquire(0, 3, reader):
+        assert dependent.closed
+        assert not owner.closed
+    with cache.acquire(0, 4, reader):
+        assert owner.closed
+        assert not newer.closed
+    cache.close()
+
+
+def test_prepared_cache_bounds_idle_queue_during_repeated_cache_hits(monkeypatch):
+    cache = _cache(monkeypatch, 2)
+    reader = _Reader()
+    for _ in range(256):
+        with cache.acquire(0, 0, reader) as prepared:
+            assert not prepared.closed
+        # Lazy invalidation must not retain one heap record per cache hit.
+        assert len(cache._idle_heap) <= 128
+    assert len(reader.prepared) == 1
+    cache.close()
+    assert prepared.closed
+
+
+@pytest.mark.parametrize("capacity", [0, 1, 32])
+@pytest.mark.parametrize("aligned", [False, True])
+def test_wide_prepared_query_does_not_repeatedly_scan_pinned_entries(
+    monkeypatch, capacity, aligned
+):
+    class CountedEntries(OrderedDict):
+        visited = 0
+
+        def items(self):
+            for item in super().items():
+                self.visited += 1
+                yield item
+
+        def values(self):
+            for entry in super().values():
+                self.visited += 1
+                yield entry
+
+    cache = _cache(monkeypatch, capacity)
+    entries = CountedEntries()
+    monkeypatch.setattr(cache, "_entries", entries)
+    reader = _Reader()
+    width = 128
+    with ExitStack() as stack:
+        owner = None
+        for locator in range(width):
+            prepared = stack.enter_context(
+                cache.acquire(0, locator, reader, time_owner=owner)
+            )
+            if aligned and owner is None:
+                owner = prepared
+        assert cache.size == width
+        assert all(not prepared.closed for prepared in reader.prepared)
+    assert cache.size <= capacity
+    cache.close()
+    assert all(prepared.closed for prepared in reader.prepared)
+    assert entries.visited <= 8 * width
+
+
+def test_prepared_cache_zero_keeps_active_entries_and_shared_owner(monkeypatch):
+    cache = _cache(monkeypatch, 0)
+    reader = _Reader()
+    with cache.acquire(0, 0, reader) as owner:
+        with cache.acquire(0, 1, reader, time_owner=owner) as value:
+            assert value.time_owner is owner
+            assert not owner.closed
+            assert not value.closed
+            assert cache.size == 2
+        assert value.closed
+        assert not owner.closed
+    assert owner.closed
+    assert cache.size == 0
+    cache.close()
+
+
+def test_prepared_cache_keeps_owner_until_dependent_is_evicted(monkeypatch):
+    cache = _cache(monkeypatch, 2)
+    reader = _Reader()
+    with cache.acquire(0, 0, reader) as owner:
+        with cache.acquire(0, 1, reader, time_owner=owner) as value:
+            pass
+    with cache.acquire(0, 2, reader):
+        assert value.closed
+        assert not owner.closed
+    with cache.acquire(0, 3, reader):
+        assert owner.closed
+    cache.close()
+
+
+def test_prepared_cache_single_flight_and_active_queries_survive_pressure(monkeypatch):
+    cache = _cache(monkeypatch, 1)
+    reader = _Reader()
+    preparing = threading.Event()
+    finish_preparing = threading.Event()
+    acquired = threading.Barrier(3, timeout=5)
+    release = threading.Event()
+    original = reader.prepare_series
+
+    def prepare(locator, time_owner=None, trust_index=False):
+        preparing.set()
+        assert finish_preparing.wait(timeout=5)
+        return original(locator, time_owner, trust_index=trust_index)
+
+    monkeypatch.setattr(reader, "prepare_series", prepare)
+
+    def query():
+        with cache.acquire(0, 0, reader) as prepared:
+            acquired.wait()
+            assert release.wait(timeout=5)
+            assert not prepared.closed
+            return prepared
+
+    with ThreadPoolExecutor(max_workers=2) as executor:
+        first = executor.submit(query)
+        assert preparing.wait(timeout=5)
+        second = executor.submit(query)
+        finish_preparing.set()
+        try:
+            acquired.wait()
+            assert len(reader.prepared) == 1
+            with cache.acquire(0, 1, reader) as other:
+                assert not reader.prepared[0].closed
+                assert not other.closed
+            assert other.closed
+        finally:
+            release.set()
+        assert first.result(timeout=5) is second.result(timeout=5)
+    cache.close()
+
+
+def test_prepared_cache_close_waits_for_active_lease(monkeypatch):
+    cache = _cache(monkeypatch, 0)
+    reader = _Reader()
+    closing = threading.Event()
+    closed = threading.Event()
+
+    def close():
+        closing.set()
+        cache.close()
+        closed.set()
+
+    with ThreadPoolExecutor(max_workers=1) as executor:
+        with cache.acquire(0, 0, reader) as prepared:
+            future = executor.submit(close)
+            assert closing.wait(timeout=5)
+            assert not closed.wait(timeout=0.05)
+            assert not prepared.closed
+        future.result(timeout=5)
+    assert prepared.closed
+    assert closed.is_set()
+
+
+def test_failed_preparation_can_be_retried(monkeypatch):
+    cache = _cache(monkeypatch, 1)
+    reader = _Reader()
+    original = reader.prepare_series
+
+    def fail(*_args, **_kwargs):
+        raise RuntimeError("prepare failed")
+
+    monkeypatch.setattr(reader, "prepare_series", fail)
+    with pytest.raises(RuntimeError, match="prepare failed"):
+        with cache.acquire(0, 0, reader):
+            pass
+    monkeypatch.setattr(reader, "prepare_series", original)
+    with cache.acquire(0, 0, reader) as prepared:
+        assert not prepared.closed
+    cache.close()
+
+
+def test_cache_close_waits_for_eviction_cleanup(monkeypatch):
+    cache = _cache(monkeypatch, 0)
+    reader = _Reader()
+    freeing = threading.Event()
+    finish_freeing = threading.Event()
+    closing = threading.Event()
+    closed = threading.Event()
+    original_prepare = reader.prepare_series
+
+    def prepare(*args, **kwargs):
+        prepared = original_prepare(*args, **kwargs)
+        original_close = prepared.close
+
+        def free():
+            freeing.set()
+            assert finish_freeing.wait(timeout=5)
+            original_close()
+
+        prepared.close = free
+        return prepared
+
+    monkeypatch.setattr(reader, "prepare_series", prepare)
+
+    def query():
+        with cache.acquire(0, 0, reader):
+            pass
+
+    def close():
+        closing.set()
+        cache.close()
+        closed.set()
+
+    with ThreadPoolExecutor(max_workers=2) as executor:
+        query_future = executor.submit(query)
+        assert freeing.wait(timeout=5)
+        close_future = executor.submit(close)
+        try:
+            assert closing.wait(timeout=5)
+            assert not closed.wait(timeout=0.05)
+        finally:
+            finish_freeing.set()
+        query_future.result(timeout=5)
+        close_future.result(timeout=5)
+    assert all(item.closed for item in reader.prepared)
+
+
+def test_cache_close_waits_for_preparation_and_releases_rejected_result(monkeypatch):
+    cache = _cache(monkeypatch, 1)
+    reader = _Reader()
+    preparing = threading.Event()
+    finish_preparing = threading.Event()
+    closing = threading.Event()
+    closed = threading.Event()
+    original = reader.prepare_series
+
+    def prepare(*args, **kwargs):
+        preparing.set()
+        assert finish_preparing.wait(timeout=5)
+        return original(*args, **kwargs)
+
+    monkeypatch.setattr(reader, "prepare_series", prepare)
+
+    def query():
+        with cache.acquire(0, 0, reader):
+            pytest.fail("closing cache must reject a newly prepared result")
+
+    def close():
+        closing.set()
+        cache.close()
+        closed.set()
+
+    with ThreadPoolExecutor(max_workers=2) as executor:
+        query_future = executor.submit(query)
+        assert preparing.wait(timeout=5)
+        close_future = executor.submit(close)
+        try:
+            assert closing.wait(timeout=5)
+            assert not closed.wait(timeout=0.05)
+        finally:
+            finish_preparing.set()
+        with pytest.raises(RuntimeError, match="closed"):
+            query_future.result(timeout=5)
+        close_future.result(timeout=5)
+    assert all(item.closed for item in reader.prepared)
+    assert cache.size == 0
+
+
+@pytest.mark.parametrize(
+    "option",
+    [
+        "max_prepared_series",
+        "descriptor_cache_size",
+        "max_open_files",
+        "query_workers",
+        "query_parallel_min_rows",
+    ],
+)
+@pytest.mark.parametrize("value", [-1, 1.5, True, "32"])
+def test_dataframe_rejects_invalid_options_before_reading_files(option, value):
+    with pytest.raises((ValueError, TypeError), match=option):
+        TsFileDataFrame("missing.tsfile", use_index=True, **{option: value})
+
+
+def test_dataframe_rejects_ignored_options_without_index():
+    with pytest.raises(ValueError, match="use_index=True"):
+        TsFileDataFrame("missing.tsfile", max_prepared_series=32)
diff --git a/python/tsfile/dataset/_config.py b/python/tsfile/dataset/_config.py
new file mode 100644
index 000000000..0fbdd22ea
--- /dev/null
+++ b/python/tsfile/dataset/_config.py
@@ -0,0 +1,66 @@
+# 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.
+
+"""Resolve per-runtime read options before opening dataset resources."""
+
+import operator
+import os
+
+DEFAULT_CACHE_SIZE = 4096
+
+
+def resolve_read_options(
+    *,
+    max_prepared_series=None,
+    descriptor_cache_size=None,
+    max_open_files=None,
+    query_workers=None,
+    query_parallel_min_rows=None,
+):
+    """Use explicit values, then environment variables, then built-in defaults."""
+    defaults = {
+        "max_prepared_series": (max_prepared_series, DEFAULT_CACHE_SIZE, 0),
+        "descriptor_cache_size": (descriptor_cache_size, DEFAULT_CACHE_SIZE, 0),
+        "max_open_files": (max_open_files, 16, 1),
+        "query_workers": (query_workers, min(4, os.cpu_count() or 1), 1),
+        "query_parallel_min_rows": (query_parallel_min_rows, 8192, 1),
+    }
+    options = {}
+    for name, (value, default, minimum) in defaults.items():
+        source = name
+        if value is None:
+            source = "TSFILE_DATAFRAME_" + name.upper()
+            raw = os.environ.get(source)
+            if raw is None:
+                value = default
+            else:
+                try:
+                    value = int(raw)
+                except ValueError as exc:
+                    raise ValueError(f"{source} must be an integer") from exc
+        else:
+            if isinstance(value, bool):
+                raise TypeError(f"{source} must be an integer, not bool")
+            try:
+                value = operator.index(value)
+            except TypeError as exc:
+                raise TypeError(f"{source} must be an integer") from exc
+        if value < minimum:
+            requirement = "non-negative" if minimum == 0 else "positive"
+            raise ValueError(f"{source} must be {requirement}")
+        options[name] = value
+    return options
diff --git a/python/tsfile/dataset/dataframe.py b/python/tsfile/dataset/dataframe.py
index 7ccdc68f9..893419d96 100644
--- a/python/tsfile/dataset/dataframe.py
+++ b/python/tsfile/dataset/dataframe.py
@@ -30,6 +30,7 @@ from typing import Dict, List, Optional, Tuple, Union
 import numpy as np
 
 from .formatting import format_dataframe_table
+from ._config import resolve_read_options
 from .metadata import (
     MODEL_TABLE,
     MODEL_TREE,
@@ -772,19 +773,51 @@ class _LocIndexer:
 
 
 class TsFileDataFrame:
-    """Lazy-loaded unified numeric dataset view over multiple TsFile shards."""
+    """Lazy-loaded unified numeric dataset view over multiple TsFile shards.
+
+    With ``use_index=True``, ``trust_index=True`` (the default) assumes the
+    indexed TsFiles are immutable and skips source generation checks. Set
+    ``trust_index=False`` to check file sizes and modification times when
+    loading the index and acquiring readers. Index format and locator bounds
+    are always checked. ``trust_index`` has no effect when ``use_index=False``.
+
+    Keyword-only resource limits apply to ``use_index=True``. Each limit uses
+    its explicit value, or the matching ``TSFILE_DATAFRAME_<OPTION>`` environment
+    variable when None, or its built-in default. Values are fixed at creation.
+    Cache sizes accept zero to disable retention; other limits must be positive.
+    """
 
     def __init__(
         self,
         paths: Union[str, List[str]],
         show_progress: bool = True,
         use_index: bool = False,
+        *,
+        trust_index: bool = True,
+        max_prepared_series: Optional[int] = None,
+        descriptor_cache_size: Optional[int] = None,
+        max_open_files: Optional[int] = None,
+        query_workers: Optional[int] = None,
+        query_parallel_min_rows: Optional[int] = None,
     ):
         if not isinstance(use_index, bool):
             raise TypeError("use_index must be a bool")
+        if not isinstance(trust_index, bool):
+            raise TypeError("trust_index must be a bool")
+        options = dict(
+            max_prepared_series=max_prepared_series,
+            descriptor_cache_size=descriptor_cache_size,
+            max_open_files=max_open_files,
+            query_workers=query_workers,
+            query_parallel_min_rows=query_parallel_min_rows,
+        )
+        if not use_index and any(value is not None for value in options.values()):
+            raise ValueError("Dataset read options require use_index=True")
+        self._runtime_options = resolve_read_options(**options) if use_index else {}
         self._paths = _expand_paths(paths)
         self._show_progress = show_progress
         self._use_index = use_index
+        self._trust_index = trust_index
         self._readers: Dict[str, object] = {}
         self._index = _DataFrameCatalog()
         self._is_view = False
@@ -801,10 +834,12 @@ class TsFileDataFrame:
         """Create a lightweight view that reuses the parent's readers and caches."""
         obj = object.__new__(cls)
         obj._root = parent._root if parent._is_view else parent
+        obj._runtime_options = parent._runtime_options.copy()
         obj._is_view = True
         obj._paths = parent._paths
         obj._show_progress = parent._show_progress
         obj._use_index = parent._use_index
+        obj._trust_index = parent._trust_index
         obj._readers = parent._readers
         subset_refs = list(series_refs)
         obj._index = SimpleNamespace(
@@ -870,7 +905,7 @@ class TsFileDataFrame:
         from .runtime import DatasetRuntime
 
         index_path = index_path_for(self._paths)
-        if not index_matches_paths(index_path, self._paths):
+        if not index_matches_paths(index_path, self._paths, self._trust_index):
             lock_path = index_path + ".lock"
             os.makedirs(os.path.dirname(lock_path) or ".", exist_ok=True)
             with open(lock_path, "a+b") as lock_file:
@@ -880,7 +915,7 @@ class TsFileDataFrame:
                     fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
                 except ImportError:
                     pass
-                if not index_matches_paths(index_path, self._paths):
+                if not index_matches_paths(index_path, self._paths, self._trust_index):
                     self._load_metadata_without_index(TsFileSeriesReader)
                     try:
                         build_index_from_dataframe(self, index_path)
@@ -889,7 +924,9 @@ class TsFileDataFrame:
                             reader.close()
                         self._readers.clear()
 
-        self._runtime = DatasetRuntime(index_path)
+        self._runtime = DatasetRuntime(
+            index_path, trust_index=self._trust_index, **self._runtime_options
+        )
         self._runtime_lease = self._runtime.lease()
         self._index = self._runtime.catalog
         if len(self._index.series) == 0:
diff --git a/python/tsfile/dataset/index.py b/python/tsfile/dataset/index.py
index ceee2f5f3..7c1246ba0 100644
--- a/python/tsfile/dataset/index.py
+++ b/python/tsfile/dataset/index.py
@@ -499,8 +499,10 @@ def index_path_for(paths: Sequence[str]) -> str:
     )
 
 
-def index_matches_paths(path: str, paths: Sequence[str]) -> bool:
-    """Return whether an index describes exactly the current sealed files."""
+def index_matches_paths(
+    path: str, paths: Sequence[str], trust_index: bool = True
+) -> bool:
+    """Match the file set, checking source generations only when untrusted."""
     expected = sorted(os.path.abspath(item) for item in paths)
     try:
         with MappedDatasetIndex(path) as index:
@@ -511,12 +513,13 @@ def index_matches_paths(path: str, paths: Sequence[str]) -> bool:
                 record = index.record(TSFILE_RECORD, file_id)
                 file_path = index.string(record[0])
                 actual.append(file_path)
-                st = os.stat(file_path)
-                if (
-                    st.st_size != record[2]
-                    or file_fingerprint(file_path, st) != record[3]
-                ):
-                    return False
+                if not trust_index:
+                    st = os.stat(file_path)
+                    if (
+                        st.st_size != record[2]
+                        or file_fingerprint(file_path, st) != record[3]
+                    ):
+                        return False
             return actual == expected
     except (OSError, ValueError, UnicodeError, IndexError):
         return False
diff --git a/python/tsfile/dataset/runtime.py b/python/tsfile/dataset/runtime.py
index dca1f5993..ab9df4b2a 100644
--- a/python/tsfile/dataset/runtime.py
+++ b/python/tsfile/dataset/runtime.py
@@ -24,6 +24,7 @@ from collections.abc import Mapping, Sequence
 import contextlib
 from concurrent.futures import ThreadPoolExecutor, wait
 from dataclasses import dataclass
+import heapq
 import os
 import sys
 import threading
@@ -35,6 +36,7 @@ from ..constants import NUMERIC_DATASET_FIELD_TYPES, TSDataType
 from ..tag_filter import tag_eq, tag_is_null
 from ..tsfile_reader import TsFileReaderPy
 from ._arrow import arrow_column_to_float64
+from ._config import DEFAULT_CACHE_SIZE, resolve_read_options
 from .index import (
     COLUMN_NAME_INDEX,
     COLUMN_SCHEMA,
@@ -57,7 +59,6 @@ from .metadata import (
 )
 from .merge import build_aligned_matrix
 
-_SERIES_DESCRIPTOR_CACHE_SIZE = 4096
 _DATACLASS_SLOTS = {"slots": True} if sys.version_info >= (3, 10) else {}
 
 
@@ -151,12 +152,20 @@ class _QueryLease:
 
 
 class _ReaderSession:
-    def __init__(self, file_id: int, path: str, expected_size: int, fingerprint: int):
+    def __init__(
+        self,
+        file_id: int,
+        path: str,
+        expected_size: int,
+        fingerprint: int,
+        trust_index: bool,
+    ):
         self.file_id = file_id
         self.path = path
         self.expected_size = expected_size
         self.fingerprint = fingerprint
-        self._validate_generation()
+        if not trust_index:
+            self._validate_generation()
         self.reader = TsFileReaderPy(path)
         self.active_uses = 0
 
@@ -178,8 +187,11 @@ class _ReaderSession:
 class ReaderSessionPool:
     """Per-Runtime LRU pool with a hard cap on simultaneously open Readers."""
 
-    def __init__(self, index: MappedDatasetIndex, max_open_files: int):
+    def __init__(
+        self, index: MappedDatasetIndex, max_open_files: int, trust_index: bool = True
+    ):
         self._index = index
+        self._trust_index = trust_index
         self.max_open_files = max(1, int(max_open_files))
         self._sessions: "OrderedDict[int, _ReaderSession]" = OrderedDict()
         self._condition = threading.Condition()
@@ -192,6 +204,7 @@ class ReaderSessionPool:
             self._index.string(record[0]),
             record[2],
             record[3],
+            self._trust_index,
         )
 
     @contextlib.contextmanager
@@ -202,7 +215,8 @@ class ReaderSessionPool:
                     raise RuntimeError("ReaderSessionPool is closed")
                 session = self._sessions.get(file_id)
                 if session is not None:
-                    session._validate_generation()
+                    if not self._trust_index:
+                        session._validate_generation()
                     self._sessions.move_to_end(file_id)
                     session.active_uses += 1
                     break
@@ -251,14 +265,44 @@ class ReaderSessionPool:
             return len(self._sessions)
 
 
+@dataclass
+class _PreparedSeriesEntry:
+    prepared: object
+    key: Tuple[int, int, int]
+    last_used: int
+    active_uses: int = 0
+    time_owner: Optional["_PreparedSeriesEntry"] = None
+    dependent_uses: int = 0
+
+
 class PreparedSeriesCache:
-    """Runtime-wide single-flight cache of native exact-locator metadata."""
+    """Single-flight LRU; active queries and shared time owners stay pinned."""
 
-    def __init__(self, index: MappedDatasetIndex):
+    def __init__(
+        self,
+        index: MappedDatasetIndex,
+        max_entries: int = DEFAULT_CACHE_SIZE,
+        *,
+        trust_index: bool = True,
+    ):
+        if isinstance(max_entries, bool) or not isinstance(max_entries, int):
+            raise TypeError("max_entries must be an integer")
+        if max_entries < 0:
+            raise ValueError("max_entries must be non-negative")
         self._index = index
+        self._trust_index = trust_index
+        self.max_entries = max_entries
         self._condition = threading.Condition()
         self._entries = {}
+        self._entries_by_prepared_id = {}
+        self._idle_entries = {}
+        # Release/unpin order may differ from access order, so use a heap to
+        # restore an idle entry at its original LRU position.
+        self._idle_heap = []
+        self._access_order = 0
+        self._active_leases = 0
         self._loading = set()
+        self._pending_closes = 0
         self._closed = False
 
     def _locator_tuple(self, file_id, locator_id):
@@ -286,48 +330,178 @@ class PreparedSeriesCache:
             device_span_length,
         )
 
-    def get(self, file_id, locator_id, reader, time_owner=None):
+    def _compact_idle_heap_locked(self):
+        # Cache hits invalidate old heap records. Bound those records even when
+        # a hot working set never exceeds the retention limit. Only idle entries
+        # are traversed; wide queries can pin arbitrarily many active entries.
+        if len(self._idle_heap) > 2 * len(self._idle_entries) + 64:
+            self._idle_heap = [
+                (last_used, key) for key, last_used in self._idle_entries.items()
+            ]
+            heapq.heapify(self._idle_heap)
+
+    def _mark_idle_locked(self, entry):
+        if entry.active_uses == 0 and entry.dependent_uses == 0:
+            self._idle_entries[entry.key] = entry.last_used
+            heapq.heappush(self._idle_heap, (entry.last_used, entry.key))
+        self._compact_idle_heap_locked()
+
+    def _oldest_idle_key_locked(self):
+        while self._idle_heap:
+            last_used, key = heapq.heappop(self._idle_heap)
+            if self._idle_entries.get(key) == last_used:
+                return key
+        return None
+
+    def _pop_entry_locked(self, key):
+        entry = self._entries.pop(key)
+        self._entries_by_prepared_id.pop(id(entry.prepared))
+        self._idle_entries.pop(key, None)
+        if entry.time_owner is not None:
+            entry.time_owner.dependent_uses -= 1
+            self._mark_idle_locked(entry.time_owner)
+            entry.time_owner = None
+        self._compact_idle_heap_locked()
+        return entry.prepared
+
+    def _evict_idle_locked(self):
+        evicted = []
+        while len(self._entries) > self.max_entries:
+            idle_key = self._oldest_idle_key_locked()
+            if idle_key is None:
+                break
+            evicted.append(self._pop_entry_locked(idle_key))
+        self._pending_closes += len(evicted)
+        return evicted
+
+    @staticmethod
+    def _close_entries(entries):
+        first_error = None
+        for prepared in entries:
+            try:
+                prepared.close()
+            except BaseException as exc:
+                if first_error is None:
+                    first_error = exc
+        if first_error is not None:
+            raise first_error
+
+    def _close_evicted(self, entries):
+        try:
+            self._close_entries(entries)
+        finally:
+            with self._condition:
+                self._pending_closes -= len(entries)
+                self._condition.notify_all()
+
+    def _release(self, entry):
+        with self._condition:
+            entry.active_uses -= 1
+            self._active_leases -= 1
+            self._mark_idle_locked(entry)
+            evicted = self._evict_idle_locked()
+            self._condition.notify_all()
+        self._close_evicted(evicted)
+
+    @contextlib.contextmanager
+    def acquire(self, file_id, locator_id, reader, time_owner=None):
         key = (id(self._index), file_id, locator_id)
+        entry = None
+        owner_entry = None
+        prepared_time_owner = time_owner
         with self._condition:
             while True:
                 if self._closed:
                     raise RuntimeError("PreparedSeriesCache is closed")
-                result = self._entries.get(key)
-                if result is not None:
-                    return result
+                entry = self._entries.get(key)
+                if entry is not None:
+                    self._idle_entries.pop(key, None)
+                    self._access_order += 1
+                    entry.last_used = self._access_order
+                    entry.active_uses += 1
+                    self._active_leases += 1
+                    self._compact_idle_heap_locked()
+                    break
                 if key not in self._loading:
+                    if time_owner is not None:
+                        requested_owner = self._entries_by_prepared_id.get(
+                            id(time_owner)
+                        )
+                        if (
+                            requested_owner is None
+                            or requested_owner.prepared is not time_owner
+                            or requested_owner.active_uses == 0
+                        ):
+                            raise RuntimeError(
+                                "PreparedSeries time owner requires an active cache lease"
+                            )
+                        owner_entry = requested_owner.time_owner or requested_owner
+                        prepared_time_owner = owner_entry.prepared
                     self._loading.add(key)
                     break
                 self._condition.wait()
-        try:
-            result = reader.prepare_series(
-                self._locator_tuple(file_id, locator_id), time_owner=time_owner
-            )
-        except Exception:
+        evicted = []
+        if entry is None:
+            try:
+                result = reader.prepare_series(
+                    self._locator_tuple(file_id, locator_id),
+                    time_owner=prepared_time_owner,
+                    trust_index=self._trust_index,
+                )
+            except BaseException:
+                with self._condition:
+                    self._loading.remove(key)
+                    self._condition.notify_all()
+                raise
             with self._condition:
-                self._loading.remove(key)
-                self._condition.notify_all()
-            raise
-        with self._condition:
-            if self._closed:
-                result.close()
-                self._loading.remove(key)
+                rejected = self._closed
+                if not rejected:
+                    self._access_order += 1
+                    entry = _PreparedSeriesEntry(
+                        result,
+                        key=key,
+                        last_used=self._access_order,
+                        active_uses=1,
+                        time_owner=owner_entry,
+                    )
+                    self._active_leases += 1
+                    if owner_entry is not None:
+                        owner_entry.dependent_uses += 1
+                    self._entries[key] = entry
+                    self._entries_by_prepared_id[id(result)] = entry
+                    self._loading.remove(key)
+                    evicted = self._evict_idle_locked()
                 self._condition.notify_all()
+            if rejected:
+                # Keep preparation in flight until its rejected handle is freed,
+                # so close() cannot return while native cleanup is still pending.
+                try:
+                    result.close()
+                finally:
+                    with self._condition:
+                        self._loading.remove(key)
+                        self._condition.notify_all()
                 raise RuntimeError("PreparedSeriesCache is closed")
-            self._entries[key] = result
-            self._loading.remove(key)
-            self._condition.notify_all()
-            return result
+
+        try:
+            self._close_evicted(evicted)
+            yield entry.prepared
+        finally:
+            self._release(entry)
 
     def close(self):
         with self._condition:
             self._closed = True
-            while self._loading:
+            while self._loading or self._pending_closes or self._active_leases:
                 self._condition.wait()
-            entries = list(self._entries.values())
-            self._entries.clear()
-        for prepared in entries:
-            prepared.close()
+            entries = []
+            while self._entries:
+                # Release values before the shared time metadata they depend on.
+                leaf_key = self._oldest_idle_key_locked()
+                assert leaf_key is not None
+                entries.append(self._pop_entry_locked(leaf_key))
+            self._pending_closes += len(entries)
+        self._close_evicted(entries)
 
     @property
     def size(self):
@@ -342,30 +516,22 @@ class DatasetRuntime:
         max_open_files: Optional[int] = None,
         query_workers: Optional[int] = None,
         query_parallel_min_rows: Optional[int] = None,
+        *,
+        trust_index: bool = True,
+        max_prepared_series: Optional[int] = None,
+        descriptor_cache_size: Optional[int] = None,
     ):
-        self.index = MappedDatasetIndex(path)
-        maximum = (
-            int(os.environ.get("TSFILE_DATAFRAME_MAX_OPEN_FILES", "16"))
-            if max_open_files is None
-            else max_open_files
-        )
-        workers = (
-            int(
-                os.environ.get(
-                    "TSFILE_DATAFRAME_QUERY_WORKERS",
-                    str(min(4, os.cpu_count() or 1)),
-                )
-            )
-            if query_workers is None
-            else query_workers
-        )
-        self.query_workers = max(1, int(workers))
-        minimum_rows = (
-            int(os.environ.get("TSFILE_DATAFRAME_QUERY_PARALLEL_MIN_ROWS", "8192"))
-            if query_parallel_min_rows is None
-            else query_parallel_min_rows
+        options = resolve_read_options(
+            max_prepared_series=max_prepared_series,
+            descriptor_cache_size=descriptor_cache_size,
+            max_open_files=max_open_files,
+            query_workers=query_workers,
+            query_parallel_min_rows=query_parallel_min_rows,
         )
-        self.query_parallel_min_rows = max(1, int(minimum_rows))
+        self.query_workers = options["query_workers"]
+        self.query_parallel_min_rows = options["query_parallel_min_rows"]
+        self.descriptor_cache_size = options["descriptor_cache_size"]
+        self.index = MappedDatasetIndex(path)
         self._query_executor = (
             ThreadPoolExecutor(
                 max_workers=self.query_workers,
@@ -374,8 +540,12 @@ class DatasetRuntime:
             if self.query_workers > 1
             else None
         )
-        self.readers = ReaderSessionPool(self.index, maximum)
-        self.prepared = PreparedSeriesCache(self.index)
+        self.readers = ReaderSessionPool(
+            self.index, options["max_open_files"], trust_index=trust_index
+        )
+        self.prepared = PreparedSeriesCache(
+            self.index, options["max_prepared_series"], trust_index=trust_index
+        )
         self._condition = threading.Condition()
         self._object_leases = 0
         self._query_leases = 0
@@ -689,7 +859,7 @@ class MappedDataFrameCatalog:
         self.runtime = runtime
         self.index = runtime.index
         self.index_identity = runtime.index.identity
-        self._descriptor_cache_size = _SERIES_DESCRIPTOR_CACHE_SIZE
+        self._descriptor_cache_size = runtime.descriptor_cache_size
         self._descriptor_cache = OrderedDict()
         self._descriptor_cache_lock = threading.Lock()
         self.table_entries = _TableMapping(self)
@@ -928,14 +1098,16 @@ class RuntimeSeriesReader:
         limit=None,
     ):
         with self.runtime.readers.acquire(self.file_id) as reader:
-            prepared = self.runtime.prepared.get(self.file_id, locator_id, reader)
-            if offset is None:
-                result = reader.query_prepared(
-                    prepared, start_time=start_time, end_time=end_time
-                )
-            else:
-                result = reader.query_prepared(prepared, offset=offset, limit=limit)
-            return self._consume(result)
+            with self.runtime.prepared.acquire(
+                self.file_id, locator_id, reader
+            ) as prepared:
+                if offset is None:
+                    result = reader.query_prepared(
+                        prepared, start_time=start_time, end_time=end_time
+                    )
+                else:
+                    result = reader.query_prepared(prepared, offset=offset, limit=limit)
+                return self._consume(result)
 
     def read_series_by_ref(self, device_id, column_id, start_time, end_time):
         return self._query(device_id, column_id, start_time, end_time)
@@ -975,12 +1147,16 @@ class RuntimeSeriesReader:
             column_names = [
                 self._identity(device_id, column_id)[3] for column_id in column_ids
             ]
-            with self.runtime.readers.acquire(self.file_id) as reader:
+            with self.runtime.readers.acquire(
+                self.file_id
+            ) as reader, contextlib.ExitStack() as stack:
                 prepared = []
                 time_owner = None
                 for span in spans:
-                    current = self.runtime.prepared.get(
-                        self.file_id, span[0], reader, time_owner=time_owner
+                    current = stack.enter_context(
+                        self.runtime.prepared.acquire(
+                            self.file_id, span[0], reader, time_owner=time_owner
+                        )
                     )
                     prepared.append(current)
                     if time_owner is None:
diff --git a/python/tsfile/tsfile_cpp.pxd b/python/tsfile/tsfile_cpp.pxd
index a30de676d..2015359ee 100644
--- a/python/tsfile/tsfile_cpp.pxd
+++ b/python/tsfile/tsfile_cpp.pxd
@@ -307,10 +307,11 @@ cdef extern from "cwrapper/tsfile_cwrapper.h":
 
     PreparedSeriesHandle tsfile_reader_prepare_series(
         TsFileReader reader, const TsFilePreparedLocator * locator,
-        ErrorCode * err_code) nogil
+        bint trust_index, ErrorCode * err_code) nogil
     PreparedSeriesHandle tsfile_reader_prepare_series_with_time_owner(
         TsFileReader reader, const TsFilePreparedLocator * locator,
-        PreparedSeriesHandle aligned_time_owner, ErrorCode * err_code) nogil
+        PreparedSeriesHandle aligned_time_owner, bint trust_index,
+        ErrorCode * err_code) nogil
     void tsfile_prepared_series_free(PreparedSeriesHandle prepared)
     ResultSet tsfile_reader_query_prepared(
         TsFileReader reader, PreparedSeriesHandle prepared,
diff --git a/python/tsfile/tsfile_py_cpp.pxd b/python/tsfile/tsfile_py_cpp.pxd
index 098caa854..d20a66c63 100644
--- a/python/tsfile/tsfile_py_cpp.pxd
+++ b/python/tsfile/tsfile_py_cpp.pxd
@@ -44,10 +44,10 @@ cdef public api void free_c_row_record(TsRecord record)
 cdef public api TsFileWriter tsfile_writer_new_c(object pathname, uint64_t memory_threshold) except NULL
 cdef public api TsFileReader tsfile_reader_new_c(object pathname) except NULL
 cdef public api PreparedSeriesHandle tsfile_reader_prepare_series_c(
-    TsFileReader reader, object locator) except NULL
+    TsFileReader reader, object locator, bint trust_index) except NULL
 cdef public api PreparedSeriesHandle tsfile_reader_prepare_series_with_time_owner_c(
     TsFileReader reader, object locator,
-    PreparedSeriesHandle aligned_time_owner) except NULL
+    PreparedSeriesHandle aligned_time_owner, bint trust_index) except NULL
 cdef public api ResultSet tsfile_reader_query_prepared_c(
     TsFileReader reader, PreparedSeriesHandle prepared, int64_t start_time,
     int64_t end_time, int offset, int limit)
diff --git a/python/tsfile/tsfile_py_cpp.pyx b/python/tsfile/tsfile_py_cpp.pyx
index 8290add50..6fa557bf5 100644
--- a/python/tsfile/tsfile_py_cpp.pyx
+++ b/python/tsfile/tsfile_py_cpp.pyx
@@ -792,7 +792,7 @@ cdef TsFileReader tsfile_reader_new_c(object pathname) except NULL:
     return reader
 
 cdef PreparedSeriesHandle tsfile_reader_prepare_series_c(
-        TsFileReader reader, object locator) except NULL:
+        TsFileReader reader, object locator, bint trust_index) except NULL:
     cdef TsFilePreparedLocator native
     cdef ErrorCode code = 0
     native.mapped_index_identity = locator[0]
@@ -808,13 +808,15 @@ cdef PreparedSeriesHandle tsfile_reader_prepare_series_c(
     native.time_metadata_length = locator[10]
     cdef PreparedSeriesHandle prepared
     with nogil:
-        prepared = tsfile_reader_prepare_series(reader, &native, &code)
+        prepared = tsfile_reader_prepare_series(
+            reader, &native, trust_index, &code
+        )
     check_error(code, b"Failed to prepare Dataset Index locator")
     return prepared
 
 cdef PreparedSeriesHandle tsfile_reader_prepare_series_with_time_owner_c(
         TsFileReader reader, object locator,
-        PreparedSeriesHandle aligned_time_owner) except NULL:
+        PreparedSeriesHandle aligned_time_owner, bint trust_index) except NULL:
     cdef TsFilePreparedLocator native
     cdef ErrorCode code = 0
     native.mapped_index_identity = locator[0]
@@ -831,7 +833,7 @@ cdef PreparedSeriesHandle tsfile_reader_prepare_series_with_time_owner_c(
     cdef PreparedSeriesHandle prepared
     with nogil:
         prepared = tsfile_reader_prepare_series_with_time_owner(
-            reader, &native, aligned_time_owner, &code
+            reader, &native, aligned_time_owner, trust_index, &code
         )
     check_error(code, b"Failed to prepare aligned Dataset Index locator")
     return prepared
diff --git a/python/tsfile/tsfile_reader.pyx b/python/tsfile/tsfile_reader.pyx
index c7f0f1a61..a5d7446b5 100644
--- a/python/tsfile/tsfile_reader.pyx
+++ b/python/tsfile/tsfile_reader.pyx
@@ -547,18 +547,19 @@ cdef class TsFileReaderPy:
         self.activate_result_set_list.add(pyresult)
         return pyresult
 
-    def prepare_series(self, locator, PreparedSeriesPy time_owner=None) -> PreparedSeriesPy:
-        """Prepare an 11-field native locator tuple for repeated queries."""
+    def prepare_series(self, locator, PreparedSeriesPy time_owner=None,
+                       bint trust_index=False) -> PreparedSeriesPy:
+        """Prepare a locator, optionally skipping source generation checks."""
         if len(locator) != 11:
             raise ValueError("prepared locator must contain exactly 11 fields")
         cdef PreparedSeriesHandle prepared = NULL
         if time_owner is None:
-            prepared = tsfile_reader_prepare_series_c(self.reader, locator)
+            prepared = tsfile_reader_prepare_series_c(self.reader, locator, trust_index)
         else:
             if time_owner.prepared == NULL:
                 raise RuntimeError("PreparedSeries time owner is closed")
             prepared = tsfile_reader_prepare_series_with_time_owner_c(
-                self.reader, locator, time_owner.prepared)
+                self.reader, locator, time_owner.prepared, trust_index)
         py_prepared = PreparedSeriesPy()
         py_prepared.init_c(prepared)
         return py_prepared