This is an automated email from the ASF dual-hosted git repository.
ColinLeeo pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/tsfile.git
The following commit(s) were added to refs/heads/develop by this push:
new e6c0ddd23 fix(python): make dataframe index opt-in (#919)
e6c0ddd23 is described below
commit e6c0ddd237df5f00068a5da2beb12fb40763f2fb
Author: Colin Lee <[email protected]>
AuthorDate: Thu Aug 27 09:07:35 2026 +0800
fix(python): make dataframe index opt-in (#919)
* fix(python): make dataframe index opt-in
* test(python): cover dataframe index modes
---
python/tests/test_dataset_index.py | 77 ++++++++--
python/tests/test_tsfile_dataset.py | 295 ++++++++++++++++++++++++++----------
python/tsfile/dataset/dataframe.py | 80 +++++++---
python/tsfile/dataset/index.py | 16 +-
4 files changed, 355 insertions(+), 113 deletions(-)
diff --git a/python/tests/test_dataset_index.py
b/python/tests/test_dataset_index.py
index 417b54d58..91761a11a 100644
--- a/python/tests/test_dataset_index.py
+++ b/python/tests/test_dataset_index.py
@@ -244,14 +244,14 @@ def _write_runtime_devices_file(path):
def test_hot_construction_maps_index_without_opening_readers(tmp_path,
monkeypatch):
source = tmp_path / "part.tsfile"
_write_runtime_file(source, 0)
- with TsFileDataFrame(str(source), show_progress=False) as first:
+ with TsFileDataFrame(str(source), show_progress=False, use_index=True) as
first:
assert len(first) == 1
def fail_legacy_scan(*_args, **_kwargs):
raise AssertionError("hot construction must not build a legacy
catalog")
monkeypatch.setattr("tsfile.dataset.reader.TsFileSeriesReader",
fail_legacy_scan)
- with TsFileDataFrame(str(source), show_progress=False) as second:
+ with TsFileDataFrame(str(source), show_progress=False, use_index=True) as
second:
assert len(second) == 1
assert second._runtime.readers.open_count == 0
series = second[0]
@@ -264,11 +264,58 @@ def
test_hot_construction_maps_index_without_opening_readers(tmp_path, monkeypat
series.close()
-def test_named_selection_reuses_bounded_runtime_descriptor(tmp_path,
monkeypatch):
+def test_dataframe_does_not_use_or_create_index_by_default(tmp_path):
source = tmp_path / "part.tsfile"
_write_runtime_file(source, 0)
+ index_path = index_module.index_path_for([str(source)])
with TsFileDataFrame(str(source), show_progress=False) as dataframe:
+ assert dataframe._runtime is None
+ assert len(dataframe) == 1
+ np.testing.assert_array_equal(dataframe[0][:], np.array([0.0, 1.0]))
+ aligned = dataframe.loc[0:1, [0]]
+ np.testing.assert_array_equal(aligned.timestamps, np.array([0, 1]))
+ np.testing.assert_array_equal(aligned.values, np.array([[0.0], [1.0]]))
+
+ assert not os.path.exists(index_path)
+
+
+def test_persistent_index_path_is_scoped_to_expanded_file_set(tmp_path,
monkeypatch):
+ first = tmp_path / "part1.tsfile"
+ second = tmp_path / "part2.tsfile"
+ third = tmp_path / "part3.tsfile"
+ _write_runtime_file(first, 0)
+ _write_runtime_file(second, 2)
+ _write_runtime_file(third, 10)
+
+ first_set = [str(first), str(second)]
+ second_set = [str(first), str(third)]
+ first_index = index_module.index_path_for(first_set)
+ second_index = index_module.index_path_for(second_set)
+ assert first_index != second_index
+
+ with TsFileDataFrame(first_set, show_progress=False, use_index=True) as
dataframe:
+ assert len(dataframe) == 1
+ with TsFileDataFrame(second_set, show_progress=False, use_index=True) as
dataframe:
+ assert len(dataframe) == 1
+ assert os.path.exists(first_index)
+ assert os.path.exists(second_index)
+
+ def fail_legacy_scan(*_args, **_kwargs):
+ raise AssertionError("matching file-set index should be reused")
+
+ monkeypatch.setattr("tsfile.dataset.reader.TsFileSeriesReader",
fail_legacy_scan)
+ with TsFileDataFrame(first_set, show_progress=False, use_index=True) as
dataframe:
+ np.testing.assert_array_equal(dataframe[0][:], np.array([0.0, 1.0,
2.0, 3.0]))
+ with TsFileDataFrame(second_set, show_progress=False, use_index=True) as
dataframe:
+ np.testing.assert_array_equal(dataframe[0][:], np.array([0.0, 1.0,
10.0, 11.0]))
+
+
+def test_named_selection_reuses_bounded_runtime_descriptor(tmp_path,
monkeypatch):
+ source = tmp_path / "part.tsfile"
+ _write_runtime_file(source, 0)
+
+ with TsFileDataFrame(str(source), show_progress=False, use_index=True) as
dataframe:
name = str(dataframe.list_timeseries()[0])
find_device_calls = 0
original_find_device = dataframe._runtime.index.find_device_id
@@ -307,7 +354,7 @@ def
test_listed_series_path_resolves_directly_by_snapshot_series_id(
source = tmp_path / "part.tsfile"
_write_runtime_file(source, 0)
- with TsFileDataFrame(str(source), show_progress=False) as dataframe:
+ with TsFileDataFrame(str(source), show_progress=False, use_index=True) as
dataframe:
path = dataframe.list_timeseries()[0]
assert isinstance(path, str)
assert path.series_id == 0
@@ -346,9 +393,13 @@ def
test_series_path_from_another_index_falls_back_to_its_name(tmp_path, monkeyp
_write_runtime_file(first_source, 0)
_write_runtime_file(second_source, 10)
- with TsFileDataFrame(str(first_source), show_progress=False) as first:
+ with TsFileDataFrame(
+ str(first_source), show_progress=False, use_index=True
+ ) as first:
foreign_path = first.list_timeseries()[0]
- with TsFileDataFrame(str(second_source), show_progress=False) as
second:
+ with TsFileDataFrame(
+ str(second_source), show_progress=False, use_index=True
+ ) as second:
assert foreign_path._index_identity !=
second._runtime.index.identity
find_device_calls = 0
original_find_device = second._runtime.index.find_device_id
@@ -372,7 +423,7 @@ def
test_runtime_descriptor_cache_evicts_least_recent_name(tmp_path, monkeypatch
_write_runtime_devices_file(source)
monkeypatch.setattr(runtime_module, "_SERIES_DESCRIPTOR_CACHE_SIZE", 2)
- with TsFileDataFrame(str(source), show_progress=False) as dataframe:
+ with TsFileDataFrame(str(source), show_progress=False, use_index=True) as
dataframe:
names = [str(name) for name in dataframe.list_timeseries()]
find_device_calls = 0
original_find_device = dataframe._runtime.index.find_device_id
@@ -403,7 +454,9 @@ def test_reader_pool_enforces_open_file_cap(tmp_path,
monkeypatch):
_write_runtime_file(first, 0)
_write_runtime_file(second, 2)
monkeypatch.setenv("TSFILE_DATAFRAME_MAX_OPEN_FILES", "1")
- with TsFileDataFrame([str(first), str(second)], show_progress=False) as
dataframe:
+ with TsFileDataFrame(
+ [str(first), str(second)], show_progress=False, use_index=True
+ ) as dataframe:
series = dataframe[0]
assert list(series[:]) == [0.0, 1.0, 2.0, 3.0]
assert dataframe._runtime.readers.open_count == 1
@@ -481,7 +534,7 @@ def
test_prepared_query_reads_nullable_offset_window_in_arrow_batches(tmp_path):
)
)
- with TsFileDataFrame(str(source), show_progress=False) as dataframe:
+ with TsFileDataFrame(str(source), show_progress=False, use_index=True) as
dataframe:
runtime = dataframe._runtime
series = runtime.index.record(LOGICAL_SERIES, 0)
span = runtime.index.record(SERIES_FILE_SPAN, series[2])
@@ -514,7 +567,7 @@ 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):
source = tmp_path / "part.tsfile"
_write_runtime_file(source, 0)
- with TsFileDataFrame(str(source), show_progress=False) as dataframe:
+ with TsFileDataFrame(str(source), show_progress=False, use_index=True) as
dataframe:
runtime = dataframe._runtime
series = runtime.index.record(LOGICAL_SERIES, 0)
span = runtime.index.record(SERIES_FILE_SPAN, series[2])
@@ -534,7 +587,7 @@ def
test_prepared_locator_rejects_stale_generation_and_bad_range(tmp_path):
def test_reader_session_revalidates_generation_when_reused(tmp_path):
source = tmp_path / "part.tsfile"
_write_runtime_file(source, 0)
- with TsFileDataFrame(str(source), show_progress=False) as dataframe:
+ with TsFileDataFrame(str(source), show_progress=False, use_index=True) as
dataframe:
pool = dataframe._runtime.readers
with pool.acquire(0):
pass
@@ -548,7 +601,7 @@ def
test_reader_session_revalidates_generation_when_reused(tmp_path):
def test_runtime_lease_close_waits_for_query_lease(tmp_path):
source = tmp_path / "part.tsfile"
_write_runtime_file(source, 0)
- with TsFileDataFrame(str(source), show_progress=False) as dataframe:
+ with TsFileDataFrame(str(source), show_progress=False, use_index=True) as
dataframe:
runtime = DatasetRuntime(str(dataframe._runtime.index.path),
query_workers=1)
lease = runtime.lease()
entered = threading.Event()
diff --git a/python/tests/test_tsfile_dataset.py
b/python/tests/test_tsfile_dataset.py
index 55115987e..499f2cf2c 100644
--- a/python/tests/test_tsfile_dataset.py
+++ b/python/tests/test_tsfile_dataset.py
@@ -45,6 +45,11 @@ from tsfile.dataset.reader import (
from tsfile.dataset.runtime import RuntimeSeriesReader
[email protected](params=[False, True], ids=["no-index", "index"])
+def dataframe_use_index(request):
+ return request.param
+
+
def _write_weather_file(path, start):
schema = TableSchema(
"weather",
@@ -259,13 +264,17 @@ def
test_format_timestamp_preserves_millisecond_precision():
assert format_timestamp(1).endswith(".001")
-def test_dataset_basic_access_patterns(tmp_path, capsys):
+def test_dataset_basic_access_patterns(tmp_path, capsys, dataframe_use_index):
path1 = tmp_path / "part1.tsfile"
path2 = tmp_path / "part2.tsfile"
_write_weather_file(path1, 0)
_write_weather_file(path2, 3)
- with TsFileDataFrame([str(path1), str(path2)], show_progress=False) as
tsdf:
+ with TsFileDataFrame(
+ [str(path1), str(path2)],
+ show_progress=False,
+ use_index=dataframe_use_index,
+ ) as tsdf:
assert len(tsdf) == 2
first = tsdf[0]
@@ -303,7 +312,9 @@ def test_dataset_basic_access_patterns(tmp_path, capsys):
assert "AlignedTimeseries(6 rows, 2 series)" in capsys.readouterr().out
-def
test_dataset_loc_aligns_timestamp_union_and_preserves_requested_order(tmp_path):
+def test_dataset_loc_aligns_timestamp_union_and_preserves_requested_order(
+ tmp_path, dataframe_use_index
+):
path = tmp_path / "weather_sparse.tsfile"
_write_weather_rows_file(
path,
@@ -315,7 +326,9 @@ def
test_dataset_loc_aligns_timestamp_union_and_preserves_requested_order(tmp_pa
},
)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
aligned = tsdf.loc[
0:2,
[
@@ -341,7 +354,9 @@ def
test_dataset_loc_aligns_timestamp_union_and_preserves_requested_order(tmp_pa
assert aligned.values[2, 1] == 30.0
-def
test_dataset_loc_batches_aligned_fields_per_device_then_unions_devices(tmp_path):
+def test_dataset_loc_batches_aligned_fields_per_device_then_unions_devices(
+ tmp_path, dataframe_use_index
+):
path = tmp_path / "weather_multi_device.tsfile"
_write_weather_rows_file(
path,
@@ -358,7 +373,9 @@ def
test_dataset_loc_batches_aligned_fields_per_device_then_unions_devices(tmp_p
"weather.device_a.humidity",
"weather.device_b.humidity",
]
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
aligned = tsdf.loc[0:2, requested]
assert aligned.series_names == requested
@@ -407,7 +424,7 @@ def
test_dataset_loc_runs_independent_device_groups_concurrently(tmp_path, monke
observed_read,
)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(str(path), show_progress=False, use_index=True) as
tsdf:
aligned = tsdf.loc[
0:1,
[
@@ -452,7 +469,7 @@ def
test_dataset_loc_keeps_small_device_groups_inline(tmp_path, monkeypatch):
observed_read,
)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(str(path), show_progress=False, use_index=True) as
tsdf:
tsdf.loc[
0:1,
[
@@ -464,7 +481,7 @@ def
test_dataset_loc_keeps_small_device_groups_inline(tmp_path, monkeypatch):
assert worker_ids == {caller_id}
-def test_dataset_reads_nullable_tag_devices_in_isolation(tmp_path):
+def test_dataset_reads_nullable_tag_devices_in_isolation(tmp_path,
dataframe_use_index):
path = tmp_path / "nullable_tags.tsfile"
schema = TableSchema(
"sensors",
@@ -506,7 +523,9 @@ def
test_dataset_reads_nullable_tag_devices_in_isolation(tmp_path):
writer.write_dataframe(null_device)
writer.write_dataframe(full)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
series = tsdf.list_timeseries()
# Null tags keep their position via the \N marker; trailing nulls drop.
assert set(series) == {
@@ -592,7 +611,9 @@ def
test_split_logical_series_path_null_marker_only_whole_component():
split_logical_series_path(bad)
-def test_dataset_null_tag_positions_and_string_null_are_distinct(tmp_path):
+def test_dataset_null_tag_positions_and_string_null_are_distinct(
+ tmp_path, dataframe_use_index
+):
path = tmp_path / "null_positions.tsfile"
schema = TableSchema(
"a",
@@ -622,7 +643,9 @@ def
test_dataset_null_tag_positions_and_string_null_are_distinct(tmp_path):
)
)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
series = tsdf.list_timeseries()
# Nothing collapses: three physically distinct devices stay distinct.
assert len(series) == 3
@@ -648,11 +671,15 @@ def
test_dataset_null_tag_positions_and_string_null_are_distinct(tmp_path):
)
-def
test_dataset_loc_supports_single_timestamp_and_mixed_series_specifiers(tmp_path):
+def test_dataset_loc_supports_single_timestamp_and_mixed_series_specifiers(
+ tmp_path, dataframe_use_index
+):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 0)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
aligned = tsdf.loc[1, [0, "weather.device_a.humidity"]]
assert isinstance(aligned, AlignedTimeseries)
@@ -664,11 +691,13 @@ def
test_dataset_loc_supports_single_timestamp_and_mixed_series_specifiers(tmp_p
np.testing.assert_array_equal(aligned.values, np.array([[21.5, 52.0]]))
-def test_dataset_loc_dedups_repeated_series_specifiers(tmp_path):
+def test_dataset_loc_dedups_repeated_series_specifiers(tmp_path,
dataframe_use_index):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 0)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
humidity = "weather.device_a.humidity"
humidity_idx = tsdf.list_timeseries().index(humidity)
@@ -712,11 +741,15 @@ def
test_dataset_loc_dedups_repeated_series_specifiers(tmp_path):
)
-def
test_dataset_loc_supports_open_ended_ranges_and_negative_series_index(tmp_path):
+def test_dataset_loc_supports_open_ended_ranges_and_negative_series_index(
+ tmp_path, dataframe_use_index
+):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 100)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
aligned = tsdf.loc[:101, [-1]]
assert isinstance(aligned, AlignedTimeseries)
@@ -727,7 +760,9 @@ def
test_dataset_loc_supports_open_ended_ranges_and_negative_series_index(tmp_pa
np.testing.assert_array_equal(aligned.values, np.array([[50.0],
[52.0]]))
-def
test_dataset_loc_with_nulls_does_not_expand_beyond_requested_time_range(tmp_path):
+def test_dataset_loc_with_nulls_does_not_expand_beyond_requested_time_range(
+ tmp_path, dataframe_use_index
+):
path = tmp_path / "weather_sparse_range.tsfile"
_write_weather_rows_file(
path,
@@ -739,7 +774,9 @@ def
test_dataset_loc_with_nulls_does_not_expand_beyond_requested_time_range(tmp_
},
)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
aligned = tsdf.loc[
1:2,
[
@@ -759,7 +796,9 @@ def
test_dataset_loc_with_nulls_does_not_expand_beyond_requested_time_range(tmp_
assert np.isnan(aligned.values[1, 1])
-def
test_dataset_loc_single_timestamp_with_nulls_keeps_exact_time_window(tmp_path):
+def test_dataset_loc_single_timestamp_with_nulls_keeps_exact_time_window(
+ tmp_path, dataframe_use_index
+):
path = tmp_path / "weather_sparse_point.tsfile"
_write_weather_rows_file(
path,
@@ -771,7 +810,9 @@ def
test_dataset_loc_single_timestamp_with_nulls_keeps_exact_time_window(tmp_pat
},
)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
aligned = tsdf.loc[
1,
[
@@ -787,11 +828,15 @@ def
test_dataset_loc_single_timestamp_with_nulls_keeps_exact_time_window(tmp_pat
assert aligned.values[0, 1] == 20.0
-def test_dataset_repr_only_builds_preview_rows(tmp_path, monkeypatch):
+def test_dataset_repr_only_builds_preview_rows(
+ tmp_path, monkeypatch, dataframe_use_index
+):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 0)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
tsdf._index.series = [(0, 0)] * 1000
built_rows = []
@@ -822,11 +867,15 @@ def test_dataset_repr_only_builds_preview_rows(tmp_path,
monkeypatch):
assert len(built_rows) == 20
-def test_dataset_exposes_only_numeric_fields_and_keeps_nan(tmp_path):
+def test_dataset_exposes_only_numeric_fields_and_keeps_nan(
+ tmp_path, dataframe_use_index
+):
path = tmp_path / "numeric_and_text.tsfile"
_write_numeric_and_text_file(path)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
assert tsdf.list_timeseries() == ["weather.device_a.temperature"]
series = tsdf[0]
@@ -844,7 +893,9 @@ def
test_dataset_exposes_only_numeric_fields_and_keeps_nan(tmp_path):
assert series[1:1].shape == (0,)
-def test_dataset_omits_table_model_phantom_series_for_skipped_cells(tmp_path):
+def test_dataset_omits_table_model_phantom_series_for_skipped_cells(
+ tmp_path, dataframe_use_index
+):
"""Schema-declared fields that a device never wrote must NOT appear.
The dataset surface treats a series as "data physically written for one
@@ -900,7 +951,9 @@ def
test_dataset_omits_table_model_phantom_series_for_skipped_cells(tmp_path):
t2.add_value_by_name("v3", 0, 330.0)
writer.write_table(t2)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
# 4 real cells: (d1,v1), (d1,v2), (d2,v1), (d2,v3); NO phantoms.
assert len(tsdf) == 4
assert sorted(tsdf.list_timeseries()) == [
@@ -926,21 +979,29 @@ def
test_dataset_omits_table_model_phantom_series_for_skipped_cells(tmp_path):
reader.close()
-def test_dataset_timeseries_supports_negative_step_slices(tmp_path):
+def test_dataset_timeseries_supports_negative_step_slices(
+ tmp_path, dataframe_use_index
+):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 0)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
series = tsdf[0]
np.testing.assert_array_equal(series[::-1], np.array([23.0, 21.5,
20.0]))
np.testing.assert_array_equal(series[::-2], np.array([23.0, 20.0]))
-def test_dataset_metadata_discovery_uses_all_numeric_fields(tmp_path):
+def test_dataset_metadata_discovery_uses_all_numeric_fields(
+ tmp_path, dataframe_use_index
+):
path = tmp_path / "partial_numeric_rows.tsfile"
_write_partial_numeric_rows_file(path)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
assert tsdf.list_timeseries() == [
"weather.device_a.temperature",
"weather.device_a.humidity",
@@ -951,14 +1012,20 @@ def
test_dataset_metadata_discovery_uses_all_numeric_fields(tmp_path):
assert list(tsdf["end_time"]) == [1, 1]
-def test_dataset_rejects_duplicate_timestamps_across_shards(tmp_path):
+def test_dataset_rejects_duplicate_timestamps_across_shards(
+ tmp_path, dataframe_use_index
+):
path1 = tmp_path / "part1.tsfile"
path2 = tmp_path / "part2.tsfile"
_write_weather_file(path1, 0)
_write_weather_file(path2, 2)
with pytest.raises(ValueError, match="Duplicate timestamp"):
- TsFileDataFrame([str(path1), str(path2)], show_progress=False)
+ TsFileDataFrame(
+ [str(path1), str(path2)],
+ show_progress=False,
+ use_index=dataframe_use_index,
+ )
def test_dataset_overlap_position_access_avoids_full_timestamp_materialization(
@@ -1009,7 +1076,7 @@ def
test_dataset_close_only_releases_current_handle(tmp_path):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 0)
- tsdf = TsFileDataFrame(str(path), show_progress=False)
+ tsdf = TsFileDataFrame(str(path), show_progress=False, use_index=True)
series = tsdf[0]
tsdf.close()
@@ -1026,7 +1093,7 @@ def
test_subset_close_releases_only_subset_lease(tmp_path):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 0)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(str(path), show_progress=False, use_index=True) as
tsdf:
subset = tsdf[:1]
subset.close()
@@ -1037,44 +1104,64 @@ def
test_subset_close_releases_only_subset_lease(tmp_path):
assert series[0] == 20.0
-def test_dataset_rejects_incompatible_table_schemas_across_shards(tmp_path):
+def test_dataset_rejects_incompatible_table_schemas_across_shards(
+ tmp_path, dataframe_use_index
+):
path1 = tmp_path / "part1.tsfile"
path2 = tmp_path / "part2.tsfile"
_write_weather_file(path1, 0)
_write_weather_with_extra_field_file(path2, 2)
with pytest.raises(ValueError, match="Incompatible schema for table
'weather'"):
- TsFileDataFrame([str(path1), str(path2)], show_progress=False)
+ TsFileDataFrame(
+ [str(path1), str(path2)],
+ show_progress=False,
+ use_index=dataframe_use_index,
+ )
-def test_dataset_rejects_same_field_name_with_different_type(tmp_path):
+def test_dataset_rejects_same_field_name_with_different_type(
+ tmp_path, dataframe_use_index
+):
path1 = tmp_path / "double.tsfile"
path2 = tmp_path / "int64.tsfile"
_write_weather_file(path1, 0)
_write_weather_int_temperature_file(path2, 3)
with pytest.raises(ValueError, match="Incompatible schema for table
'weather'"):
- TsFileDataFrame([str(path1), str(path2)], show_progress=False)
+ TsFileDataFrame(
+ [str(path1), str(path2)],
+ show_progress=False,
+ use_index=dataframe_use_index,
+ )
-def test_dataset_rejects_nonnumeric_declared_schema_difference(tmp_path):
+def test_dataset_rejects_nonnumeric_declared_schema_difference(
+ tmp_path, dataframe_use_index
+):
path1 = tmp_path / "text-status.tsfile"
path2 = tmp_path / "boolean-status.tsfile"
_write_numeric_and_text_file(path1)
_write_weather_boolean_status_file(path2, 3)
with pytest.raises(ValueError, match="Incompatible schema for table
'weather'"):
- TsFileDataFrame([str(path1), str(path2)], show_progress=False)
+ TsFileDataFrame(
+ [str(path1), str(path2)],
+ show_progress=False,
+ use_index=dataframe_use_index,
+ )
-def test_dataset_table_model_omits_boolean_fields(tmp_path):
+def test_dataset_table_model_omits_boolean_fields(tmp_path,
dataframe_use_index):
# The numeric dataset surface must drop BOOLEAN fields (they are not
# numeric) rather than surfacing them as a series that crashes the runtime
# read path. Regression for the reader/runtime field-type whitelist drift.
path = tmp_path / "weather_boolean.tsfile"
_write_weather_boolean_status_file(path, 0)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
assert tsdf.model == "table"
assert tsdf.list_timeseries() == ["weather.device_a.temperature"]
with pytest.raises(KeyError):
@@ -1103,7 +1190,7 @@ def
test_dataset_close_waits_for_an_active_public_query(tmp_path, monkeypatch):
blocked_read,
)
- dataframe = TsFileDataFrame(str(path), show_progress=False)
+ dataframe = TsFileDataFrame(str(path), show_progress=False, use_index=True)
query_done = threading.Event()
def run_query():
@@ -1137,14 +1224,16 @@ def
test_dataset_close_waits_for_an_active_public_query(tmp_path, monkeypatch):
assert query_error == []
-def test_dataset_skips_empty_tsfile_shards(tmp_path):
+def test_dataset_skips_empty_tsfile_shards(tmp_path, dataframe_use_index):
empty_path = tmp_path / "empty.tsfile"
data_path = tmp_path / "part.tsfile"
_write_empty_weather_file(empty_path)
_write_weather_file(data_path, 0)
with TsFileDataFrame(
- [str(empty_path), str(data_path)], show_progress=False
+ [str(empty_path), str(data_path)],
+ show_progress=False,
+ use_index=dataframe_use_index,
) as tsdf:
assert tsdf.list_timeseries() == [
"weather.device_a.temperature",
@@ -1164,11 +1253,13 @@ def test_reader_allows_empty_tsfile(tmp_path):
reader.close()
-def test_dataset_multi_tag_metadata_discovery(tmp_path):
+def test_dataset_multi_tag_metadata_discovery(tmp_path, dataframe_use_index):
path = tmp_path / "multi_tag.tsfile"
_write_multi_tag_file(path)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
assert tsdf.list_timeseries() == [
"weather.beijing.device_a.temperature",
"weather.beijing.device_a.humidity",
@@ -1218,11 +1309,13 @@ def test_dataset_multi_tag_metadata_discovery(tmp_path):
assert list(summary["count"]) == [2, 2, 2, 2]
-def test_dataset_series_paths_escape_special_tag_values(tmp_path):
+def test_dataset_series_paths_escape_special_tag_values(tmp_path,
dataframe_use_index):
path = tmp_path / "special_tag.tsfile"
_write_special_tag_file(path)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
expected_path = r"weather.bei\.jing.dev\\1.temperature"
assert tsdf.list_timeseries() == [expected_path]
@@ -1596,12 +1689,14 @@ def
test_dataframe_list_timeseries_filters_named_sparse_tag_prefix():
def test_dataframe_list_timeseries_prefix_can_skip_full_name_build(
- tmp_path, monkeypatch
+ tmp_path, monkeypatch, dataframe_use_index
):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 0)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
tsdf._index.series = [(0, 0)] * 1000
def fail_build_series_name(_series_ref):
@@ -1719,11 +1814,13 @@ def _write_tree_file(path):
writer.close()
-def test_dataset_tree_model_metadata_and_repr(tmp_path):
+def test_dataset_tree_model_metadata_and_repr(tmp_path, dataframe_use_index):
path = tmp_path / "tree.tsfile"
_write_tree_file(path)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
assert tsdf.model == "tree"
assert len(tsdf) == 3
assert sorted(tsdf.list_timeseries()) == [
@@ -1746,11 +1843,13 @@ def test_dataset_tree_model_metadata_and_repr(tmp_path):
tsdf["table"]
-def test_dataset_tree_model_series_access(tmp_path):
+def test_dataset_tree_model_series_access(tmp_path, dataframe_use_index):
path = tmp_path / "tree.tsfile"
_write_tree_file(path)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
ts = tsdf["root.ln.wf01.wt01.temperature"]
assert isinstance(ts, Timeseries)
assert ts.name == "root.ln.wf01.wt01.temperature"
@@ -1773,7 +1872,9 @@ def test_dataset_tree_model_series_access(tmp_path):
np.testing.assert_array_equal(aligned.timestamps, np.arange(5,
dtype=np.int64))
-def test_dataset_tree_model_reads_uppercase_measurement_names(tmp_path):
+def test_dataset_tree_model_reads_uppercase_measurement_names(
+ tmp_path, dataframe_use_index
+):
"""Tree-model series with uppercase measurement names must read data.
Regression: a measurement like ``Temperature``/``STATUS`` must return its
@@ -1802,7 +1903,9 @@ def
test_dataset_tree_model_reads_uppercase_measurement_names(tmp_path):
)
writer.close()
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
assert sorted(tsdf.list_timeseries()) == [
"root.ln.wf01.wt01.STATUS",
"root.ln.wf01.wt01.Temperature",
@@ -1817,7 +1920,9 @@ def
test_dataset_tree_model_reads_uppercase_measurement_names(tmp_path):
)
-def
test_dataset_tree_model_case_distinct_measurements_do_not_collide(tmp_path,
capsys):
+def test_dataset_tree_model_case_distinct_measurements_do_not_collide(
+ tmp_path, capsys, dataframe_use_index
+):
"""Case-distinct measurements on one device must not be conflated.
``temperature`` and ``Temperature`` are two independent series; each must
@@ -1847,7 +1952,9 @@ def
test_dataset_tree_model_case_distinct_measurements_do_not_collide(tmp_path,
)
writer.close()
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
# Two independent tree-model series are discovered (case preserved).
assert len(tsdf) == 2
assert sorted(tsdf.list_timeseries()) == [
@@ -1909,7 +2016,9 @@ def
test_dataset_tree_model_case_distinct_measurements_do_not_collide(tmp_path,
)
-def test_tree_reader_handles_stale_path_columns_after_reused_queries(tmp_path):
+def test_tree_reader_handles_stale_path_columns_after_reused_queries(
+ tmp_path, dataframe_use_index
+):
"""Reusing a reader must not leak prefix path state across queries.
Reading one device series then another reuses the cached device id; stale
@@ -1918,7 +2027,9 @@ def
test_tree_reader_handles_stale_path_columns_after_reused_queries(tmp_path):
path = tmp_path / "tree.tsfile"
_write_tree_file(path)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
# First read establishes query state on the reader.
np.testing.assert_array_equal(
tsdf["root.ln.wf01.wt01.temperature"][:],
@@ -1936,11 +2047,13 @@ def
test_tree_reader_handles_stale_path_columns_after_reused_queries(tmp_path):
)
-def test_dataset_tree_model_list_timeseries_metadata(tmp_path):
+def test_dataset_tree_model_list_timeseries_metadata(tmp_path,
dataframe_use_index):
path = tmp_path / "tree.tsfile"
_write_tree_file(path)
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
meta = tsdf.list_timeseries_metadata()
assert isinstance(meta, pd.DataFrame)
@@ -1962,14 +2075,18 @@ def
test_dataset_tree_model_list_timeseries_metadata(tmp_path):
assert meta.loc["root.ln.wf02.wt02.status", "count"] == 5
-def test_dataset_rejects_mixed_model_load(tmp_path):
+def test_dataset_rejects_mixed_model_load(tmp_path, dataframe_use_index):
table_path = tmp_path / "weather.tsfile"
tree_path = tmp_path / "tree.tsfile"
_write_weather_file(table_path, 0)
_write_tree_file(tree_path)
with pytest.raises(ValueError, match="Mixed table-model and tree-model"):
- TsFileDataFrame([str(table_path), str(tree_path)], show_progress=False)
+ TsFileDataFrame(
+ [str(table_path), str(tree_path)],
+ show_progress=False,
+ use_index=dataframe_use_index,
+ )
def _write_tree_rows(path, device_measurements, t_start=0, t_count=3):
@@ -1995,7 +2112,9 @@ def _write_tree_rows(path, device_measurements,
t_start=0, t_count=3):
writer.close()
-def test_dataset_tree_model_merges_identical_structure_across_files(tmp_path):
+def test_dataset_tree_model_merges_identical_structure_across_files(
+ tmp_path, dataframe_use_index
+):
# Two tree files, same device/measurement, disjoint time ranges: one
logical
# series whose shards and time bounds merge.
path1 = tmp_path / "t1.tsfile"
@@ -2003,7 +2122,11 @@ def
test_dataset_tree_model_merges_identical_structure_across_files(tmp_path):
_write_tree_rows(path1, {"root.a.b": [("m1", TSDataType.DOUBLE)]},
t_start=0)
_write_tree_rows(path2, {"root.a.b": [("m1", TSDataType.DOUBLE)]},
t_start=10)
- with TsFileDataFrame([str(path1), str(path2)], show_progress=False) as
tsdf:
+ with TsFileDataFrame(
+ [str(path1), str(path2)],
+ show_progress=False,
+ use_index=dataframe_use_index,
+ ) as tsdf:
assert tsdf.model == "tree"
assert tsdf.list_timeseries() == ["root.a.b.m1"]
ts = tsdf["root.a.b.m1"]
@@ -2015,14 +2138,18 @@ def
test_dataset_tree_model_merges_identical_structure_across_files(tmp_path):
assert meta.loc["root.a.b.m1", "count"] == 6
-def test_dataset_tree_model_unions_fields_across_files(tmp_path):
+def test_dataset_tree_model_unions_fields_across_files(tmp_path,
dataframe_use_index):
# Same device, different measurement subsets across files -> union of
fields.
path1 = tmp_path / "t1.tsfile"
path2 = tmp_path / "t2.tsfile"
_write_tree_rows(path1, {"root.a.b": [("m1", TSDataType.DOUBLE)]},
t_start=0)
_write_tree_rows(path2, {"root.a.b": [("m2", TSDataType.DOUBLE)]},
t_start=0)
- with TsFileDataFrame([str(path1), str(path2)], show_progress=False) as
tsdf:
+ with TsFileDataFrame(
+ [str(path1), str(path2)],
+ show_progress=False,
+ use_index=dataframe_use_index,
+ ) as tsdf:
assert tsdf.model == "tree"
assert sorted(tsdf.list_timeseries()) == ["root.a.b.m1", "root.a.b.m2"]
# Each field reads its own file's data, not the other's.
@@ -2036,7 +2163,9 @@ def
test_dataset_tree_model_unions_fields_across_files(tmp_path):
)
-def test_dataset_tree_model_unions_different_depths_across_files(tmp_path):
+def test_dataset_tree_model_unions_different_depths_across_files(
+ tmp_path, dataframe_use_index
+):
# Files with different max depth -> global tag layout widens; the shallower
# device pads its deepest tag column with null.
path1 = tmp_path / "t1.tsfile"
@@ -2044,7 +2173,11 @@ def
test_dataset_tree_model_unions_different_depths_across_files(tmp_path):
_write_tree_rows(path1, {"root.a.b": [("m1", TSDataType.DOUBLE)]},
t_start=0)
_write_tree_rows(path2, {"root.a.b.c": [("m1", TSDataType.DOUBLE)]},
t_start=0)
- with TsFileDataFrame([str(path1), str(path2)], show_progress=False) as
tsdf:
+ with TsFileDataFrame(
+ [str(path1), str(path2)],
+ show_progress=False,
+ use_index=dataframe_use_index,
+ ) as tsdf:
assert tsdf.model == "tree"
assert sorted(tsdf.list_timeseries()) == ["root.a.b.c.m1",
"root.a.b.m1"]
meta = tsdf.list_timeseries_metadata()
@@ -2060,7 +2193,9 @@ def
test_dataset_tree_model_unions_different_depths_across_files(tmp_path):
)
-def test_dataset_tree_model_omits_non_numeric_measurements(tmp_path):
+def test_dataset_tree_model_omits_non_numeric_measurements(
+ tmp_path, dataframe_use_index
+):
# The dataset surface is numeric (float64); a STRING tree measurement must
# be dropped, not surfaced as a series that crashes on read.
from tsfile import Field, RowRecord, TimeseriesSchema, TsFileWriter
@@ -2084,7 +2219,9 @@ def
test_dataset_tree_model_omits_non_numeric_measurements(tmp_path):
)
writer.close()
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
# Only the numeric measurement is exposed; the STRING one is dropped.
assert tsdf.list_timeseries() == ["root.a.b.temp"]
with pytest.raises(KeyError):
@@ -2094,7 +2231,9 @@ def
test_dataset_tree_model_omits_non_numeric_measurements(tmp_path):
)
-def test_dataset_tree_model_loc_aligns_sparse_non_aligned_fields(tmp_path):
+def test_dataset_tree_model_loc_aligns_sparse_non_aligned_fields(
+ tmp_path, dataframe_use_index
+):
# Non-aligned (tree) device whose two measurements are sampled at
# different timestamps must produce a timestamp union with NaN fill,
# instead of raising when the per-field timelines differ. Regression for
@@ -2122,7 +2261,9 @@ def
test_dataset_tree_model_loc_aligns_sparse_non_aligned_fields(tmp_path):
)
writer.close()
- with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+ with TsFileDataFrame(
+ str(path), show_progress=False, use_index=dataframe_use_index
+ ) as tsdf:
assert tsdf.model == "tree"
aligned = tsdf.loc[0:5, ["root.a.b.m1", "root.a.b.m2"]]
assert isinstance(aligned, AlignedTimeseries)
diff --git a/python/tsfile/dataset/dataframe.py
b/python/tsfile/dataset/dataframe.py
index e3ce78372..9e706b0f2 100644
--- a/python/tsfile/dataset/dataframe.py
+++ b/python/tsfile/dataset/dataframe.py
@@ -663,8 +663,28 @@ class _LocIndexer:
_, table_entry, _ = self._df._get_series_components(series_ref)
field_name = table_entry.field_columns[field_idx]
- descriptor = self._df._index.series_shards.describe(series_ref)
- for shard in descriptor.shards:
+ describe = getattr(self._df._index.series_shards, "describe", None)
+ if describe is None:
+ shard_entries = []
+ for (
+ reader,
+ device_id,
+ reader_field_idx,
+ ) in self._df._index.series_shards[series_ref]:
+ info = reader.get_series_info_by_ref(device_id,
reader_field_idx)
+ shard_entries.append(
+ SimpleNamespace(
+ reader=reader,
+ device_id=device_id,
+ column_id=reader_field_idx,
+ min_time=info["timeline_min_time"],
+ max_time=info["timeline_max_time"],
+ timeline_length=info["timeline_length"],
+ )
+ )
+ else:
+ shard_entries = describe(series_ref).shards
+ for shard in shard_entries:
overlap_start = max(start_time, shard.min_time)
overlap_end = min(end_time, shard.max_time)
if shard.timeline_length <= 0 or overlap_start > overlap_end:
@@ -710,13 +730,16 @@ class _LocIndexer:
return entries, ts_arr, field_vals
group_entries = list(groups.values())
- group_results = self._df._runtime.map_query_groups(
- query_group,
- group_entries,
- estimated_rows=[
- max(entry[6] for entry in entries) for entries in group_entries
- ],
- )
+ if self._df._runtime is None:
+ group_results = [query_group(entries) for entries in group_entries]
+ else:
+ group_results = self._df._runtime.map_query_groups(
+ query_group,
+ group_entries,
+ estimated_rows=[
+ max(entry[6] for entry in entries) for entries in
group_entries
+ ],
+ )
series_time_parts = defaultdict(list)
series_value_parts = defaultdict(list)
@@ -751,9 +774,17 @@ class _LocIndexer:
class TsFileDataFrame:
"""Lazy-loaded unified numeric dataset view over multiple TsFile shards."""
- def __init__(self, paths: Union[str, List[str]], show_progress: bool =
True):
+ def __init__(
+ self,
+ paths: Union[str, List[str]],
+ show_progress: bool = True,
+ use_index: bool = False,
+ ):
+ if not isinstance(use_index, bool):
+ raise TypeError("use_index must be a bool")
self._paths = _expand_paths(paths)
self._show_progress = show_progress
+ self._use_index = use_index
self._readers: Dict[str, object] = {}
self._index = _DataFrameCatalog()
self._is_view = False
@@ -773,6 +804,7 @@ class TsFileDataFrame:
obj._is_view = True
obj._paths = parent._paths
obj._show_progress = parent._show_progress
+ obj._use_index = parent._use_index
obj._readers = parent._readers
subset_refs = list(series_refs)
obj._index = SimpleNamespace(
@@ -808,9 +840,24 @@ class TsFileDataFrame:
with self._runtime_lease.query_lease():
yield
+ def _load_metadata_without_index(self, reader_class):
+ if len(self._paths) >= 2:
+ self._load_metadata_parallel(reader_class)
+ else:
+ self._load_metadata_serial(reader_class)
+
+ if not self._index.series:
+ raise ValueError("No valid time series found in the provided
TsFile files")
+ _validate_unique_shard_timestamps(self._index)
+
def _load_metadata(self):
- """Map a valid persistent index, or build it once under a file lock."""
+ """Load metadata, optionally through a persistent mmap-backed index."""
from .reader import TsFileSeriesReader
+
+ if not self._use_index:
+ self._load_metadata_without_index(TsFileSeriesReader)
+ return
+
from .index import (
build_index_from_dataframe,
index_matches_paths,
@@ -830,17 +877,8 @@ class TsFileDataFrame:
except ImportError:
pass
if not index_matches_paths(index_path, self._paths):
- if len(self._paths) >= 2:
- self._load_metadata_parallel(TsFileSeriesReader)
- else:
- self._load_metadata_serial(TsFileSeriesReader)
-
- if not self._index.series:
- raise ValueError(
- "No valid time series found in the provided TsFile
files"
- )
+ self._load_metadata_without_index(TsFileSeriesReader)
try:
- _validate_unique_shard_timestamps(self._index)
build_index_from_dataframe(self, index_path)
finally:
for reader in self._readers.values():
diff --git a/python/tsfile/dataset/index.py b/python/tsfile/dataset/index.py
index 394860d54..4bd9c3767 100644
--- a/python/tsfile/dataset/index.py
+++ b/python/tsfile/dataset/index.py
@@ -28,6 +28,7 @@ from __future__ import annotations
from collections import defaultdict
import contextlib
+import hashlib
import mmap
import os
import struct
@@ -43,7 +44,8 @@ HEADER_SIZE = 64
DIRECTORY_ENTRY_SIZE = 32
SECTION_COUNT = 13
ALIGNMENT = 64
-INDEX_FILE_NAME = ".tsfile_dataframe_index.tsidx"
+INDEX_FILE_PREFIX = ".tsfile_dataframe_index"
+INDEX_FILE_SUFFIX = ".tsidx"
STRING_OFFSETS = 1
STRING_BYTES = 2
@@ -460,10 +462,18 @@ class MappedDatasetIndex:
def index_path_for(paths: Sequence[str]) -> str:
- common = os.path.commonpath([os.path.abspath(path) for path in paths])
+ canonical_paths = sorted(os.path.abspath(path) for path in paths)
+ common = os.path.commonpath(canonical_paths)
if not os.path.isdir(common):
common = os.path.dirname(common)
- return os.path.join(common, INDEX_FILE_NAME)
+ digest = hashlib.sha256()
+ for path in canonical_paths:
+ digest.update(path.encode("utf-8"))
+ digest.update(b"\0")
+ return os.path.join(
+ common,
+ f"{INDEX_FILE_PREFIX}.{digest.hexdigest()[:32]}{INDEX_FILE_SUFFIX}",
+ )
def index_matches_paths(path: str, paths: Sequence[str]) -> bool: