| # 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 types import SimpleNamespace |
| import os |
| import threading |
| |
| import numpy as np |
| import pandas as pd |
| import pyarrow as pa |
| import pytest |
| |
| import tsfile.dataset.index as index_module |
| import tsfile.dataset.runtime as runtime_module |
| from tsfile import ( |
| ColumnCategory, |
| ColumnSchema, |
| TableSchema, |
| TsFileDataFrame, |
| TsFileTableWriter, |
| ) |
| from tsfile.constants import TSDataType |
| from tsfile.dataset.index import ( |
| COLUMN_SCHEMA, |
| DEVICE_FILE_SPAN, |
| DIRECTORY, |
| HEADER, |
| LOGICAL_SERIES, |
| MappedDatasetIndex, |
| RECORDS, |
| SERIES_FILE_SPAN, |
| SERIES_LOCATOR, |
| TSFILE_RECORD, |
| build_sections_from_dataframe, |
| crc32c, |
| write_index_atomic, |
| ) |
| from tsfile.dataset.metadata import MetadataCatalog, SeriesStats |
| from tsfile.dataset.runtime import RuntimeSeriesReader |
| from tsfile.dataset.runtime import DatasetRuntime |
| from tsfile.tsfile_reader import TsFileReaderPy |
| |
| |
| def _synthetic_dataframe(source_path): |
| catalog = MetadataCatalog() |
| table_id = catalog.add_table("root", (), (), ("s1",)) |
| device_id = catalog.add_device(table_id, (), 1, 10) |
| catalog.series_stats_by_ref[(device_id, 0)] = SeriesStats( |
| 10, |
| 1, |
| 10, |
| 10, |
| 1, |
| 10, |
| int(TSDataType.INT64), |
| 128, |
| 16, |
| 64, |
| 16, |
| 1, |
| 1, |
| 1, |
| 1, |
| ) |
| reader = SimpleNamespace(file_path=source_path, catalog=catalog) |
| index = SimpleNamespace( |
| table_entries={"root": catalog.table_entries[0]}, |
| devices=[("root", ())], |
| series=[(0, 0)], |
| series_shards={(0, 0): [(reader, 0, 0)]}, |
| ) |
| return SimpleNamespace( |
| _index=index, |
| _readers={source_path: reader}, |
| _paths=[source_path], |
| ) |
| |
| |
| def test_binary_layout_matches_cpp_v1(): |
| assert HEADER.size == 64 |
| assert DIRECTORY.size == 32 |
| assert RECORDS[COLUMN_SCHEMA].size == 32 |
| assert RECORDS[LOGICAL_SERIES].size == 32 |
| assert RECORDS[TSFILE_RECORD].size == 32 |
| assert RECORDS[DEVICE_FILE_SPAN].size == 32 |
| assert RECORDS[SERIES_FILE_SPAN].size == 40 |
| assert RECORDS[SERIES_LOCATOR].size == 24 |
| assert index_module.SECTION_COUNT == 13 |
| |
| |
| def test_crc32c_known_vector(): |
| assert crc32c(b"123456789") == 0xE3069283 |
| |
| |
| def test_build_publish_map_and_lookup(tmp_path): |
| source = tmp_path / "source.tsfile" |
| source.write_bytes(b"T" * 4096) |
| output = tmp_path / "dataset.tsidx" |
| dataframe = _synthetic_dataframe(str(source)) |
| write_index_atomic(str(output), build_sections_from_dataframe(dataframe)) |
| |
| with MappedDatasetIndex(str(output), verify_sections=True) as index: |
| table_id = index.find_table_ids("root")[0] |
| device_id = index.find_device_id(table_id, "root.") |
| column_id = index.find_column_id(table_id, "s1") |
| assert index.find_series_id(device_id, column_id) == 0 |
| assert index.count(LOGICAL_SERIES) == 1 |
| assert index.count(SERIES_LOCATOR) == 1 |
| file_record = index.record(TSFILE_RECORD, 0) |
| assert index.string(file_record[0]) == str(source) |
| |
| |
| def test_child_lookup_checks_full_bytes_for_hash_collisions(monkeypatch): |
| rows = [ |
| (0, 10, 42, 0, 0), |
| (0, 11, 42, 1, 0), |
| (0, 12, 42, 2, 0), |
| ] |
| names = [b"alpha", b"beta", b"gamma"] |
| monkeypatch.setattr(index_module, "name_hash", lambda _value: 42) |
| |
| class _Index: |
| @staticmethod |
| def record(_section_type, record_id): |
| return rows[record_id] |
| |
| @staticmethod |
| def string_bytes(sid): |
| return names[sid] |
| |
| assert MappedDatasetIndex._find_child(_Index(), 0, 0, "beta", 0, 3) == 11 |
| with pytest.raises(KeyError): |
| MappedDatasetIndex._find_child(_Index(), 0, 0, "missing", 0, 3) |
| |
| |
| def test_rejects_damaged_header_checksum(tmp_path): |
| source = tmp_path / "source.tsfile" |
| source.write_bytes(b"T" * 4096) |
| output = tmp_path / "dataset.tsidx" |
| write_index_atomic( |
| str(output), build_sections_from_dataframe(_synthetic_dataframe(str(source))) |
| ) |
| with output.open("r+b") as stream: |
| stream.seek(32) |
| stream.write((1).to_bytes(8, "little")) |
| with pytest.raises(ValueError, match="header shape|checksum"): |
| MappedDatasetIndex(str(output)) |
| |
| |
| def test_reader_exposes_exact_aligned_locator_ranges(tmp_path): |
| source = tmp_path / "aligned.tsfile" |
| schema = TableSchema( |
| "weather", |
| [ |
| ColumnSchema("device", TSDataType.STRING, ColumnCategory.TAG), |
| ColumnSchema("value", TSDataType.DOUBLE, ColumnCategory.FIELD), |
| ], |
| ) |
| with TsFileTableWriter(str(source), schema) as writer: |
| writer.write_dataframe( |
| pd.DataFrame( |
| { |
| "time": [0, 1, 2], |
| "device": ["d0", "d0", "d0"], |
| "value": [1.0, 2.0, 3.0], |
| } |
| ) |
| ) |
| reader = TsFileReaderPy(str(source)) |
| try: |
| groups = reader.get_timeseries_metadata() |
| metadata = [item for group in groups.values() for item in group.timeseries] |
| assert metadata |
| assert all(item.value_metadata_length > 0 for item in metadata) |
| assert all( |
| item.value_metadata_offset + item.value_metadata_length |
| <= os.path.getsize(source) |
| for item in metadata |
| ) |
| aligned = [item for item in metadata if item.layout == 1] |
| assert aligned |
| assert all(item.time_metadata_length > 0 for item in aligned) |
| assert all( |
| item.time_chunk_meta_count == item.chunk_meta_count for item in aligned |
| ) |
| finally: |
| reader.close() |
| |
| |
| def _write_runtime_file(path, start): |
| schema = TableSchema( |
| "weather", |
| [ |
| ColumnSchema("device", TSDataType.STRING, ColumnCategory.TAG), |
| ColumnSchema("value", TSDataType.DOUBLE, ColumnCategory.FIELD), |
| ], |
| ) |
| with TsFileTableWriter(str(path), schema) as writer: |
| writer.write_dataframe( |
| pd.DataFrame( |
| { |
| "time": [start, start + 1], |
| "device": ["d0", "d0"], |
| "value": [float(start), float(start + 1)], |
| } |
| ) |
| ) |
| |
| |
| def _write_runtime_devices_file(path): |
| schema = TableSchema( |
| "weather", |
| [ |
| ColumnSchema("device", TSDataType.STRING, ColumnCategory.TAG), |
| ColumnSchema("value", 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], |
| } |
| ) |
| ) |
| |
| |
| 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, 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, use_index=True) as second: |
| assert len(second) == 1 |
| assert second._runtime.readers.open_count == 0 |
| series = second[0] |
| assert second._runtime.readers.open_count == 0 |
| assert series[0] == 0.0 |
| assert second._runtime.readers.open_count == 1 |
| assert second._runtime.prepared.size == 1 |
| assert series[1] == 1.0 |
| assert second._runtime.prepared.size == 1 |
| series.close() |
| |
| |
| 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 |
| |
| def count_find_device(*args, **kwargs): |
| nonlocal find_device_calls |
| find_device_calls += 1 |
| return original_find_device(*args, **kwargs) |
| |
| def fail_series_info(*_args, **_kwargs): |
| raise AssertionError( |
| "descriptor-backed selection must not rebuild series info" |
| ) |
| |
| monkeypatch.setattr( |
| dataframe._runtime.index, "find_device_id", count_find_device |
| ) |
| monkeypatch.setattr( |
| RuntimeSeriesReader, "get_series_info_by_ref", fail_series_info |
| ) |
| |
| first = dataframe[name] |
| second = dataframe[name] |
| assert first.stats == {"start_time": 0, "end_time": 1, "count": 2} |
| np.testing.assert_array_equal(first[:], np.array([0.0, 1.0])) |
| assert second.stats == first.stats |
| assert find_device_calls == 1 |
| assert len(dataframe._index._descriptor_cache) == 1 |
| first.close() |
| second.close() |
| |
| |
| def test_listed_series_path_resolves_directly_by_snapshot_series_id( |
| 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: |
| path = dataframe.list_timeseries()[0] |
| assert isinstance(path, str) |
| assert path.series_id == 0 |
| assert path._index_identity == dataframe._runtime.index.identity |
| |
| def fail_name_lookup(*_args, **_kwargs): |
| raise AssertionError("listed SeriesPath must bypass device name lookup") |
| |
| def fail_name_rebuild(*_args, **_kwargs): |
| raise AssertionError("listed SeriesPath must not be rebuilt from the index") |
| |
| def fail_span_lookup(*_args, **_kwargs): |
| raise AssertionError("descriptor locator must bypass series span lookup") |
| |
| monkeypatch.setattr( |
| dataframe._runtime.index, "find_device_id", fail_name_lookup |
| ) |
| monkeypatch.setattr(dataframe, "_build_series_name", fail_name_rebuild) |
| monkeypatch.setattr(RuntimeSeriesReader, "_span", fail_span_lookup) |
| series = dataframe[path] |
| assert series.name is path |
| np.testing.assert_array_equal(series[:], np.array([0.0, 1.0])) |
| series.close() |
| |
| # Converting to a plain str deliberately drops the snapshot-local id. |
| assert not hasattr(str(path), "series_id") |
| |
| |
| def test_series_path_from_another_index_falls_back_to_its_name(tmp_path, monkeypatch): |
| first_dir = tmp_path / "first" |
| second_dir = tmp_path / "second" |
| first_dir.mkdir() |
| second_dir.mkdir() |
| first_source = first_dir / "part.tsfile" |
| second_source = second_dir / "part.tsfile" |
| _write_runtime_file(first_source, 0) |
| _write_runtime_file(second_source, 10) |
| |
| 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, 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 |
| |
| def count_find_device(*args, **kwargs): |
| nonlocal find_device_calls |
| find_device_calls += 1 |
| return original_find_device(*args, **kwargs) |
| |
| monkeypatch.setattr( |
| second._runtime.index, "find_device_id", count_find_device |
| ) |
| series = second[foreign_path] |
| np.testing.assert_array_equal(series[:], np.array([10.0, 11.0])) |
| assert find_device_calls == 1 |
| series.close() |
| |
| |
| 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: |
| names = [str(name) for name in dataframe.list_timeseries()] |
| find_device_calls = 0 |
| original_find_device = dataframe._runtime.index.find_device_id |
| |
| def count_find_device(*args, **kwargs): |
| nonlocal find_device_calls |
| find_device_calls += 1 |
| return original_find_device(*args, **kwargs) |
| |
| monkeypatch.setattr( |
| dataframe._runtime.index, "find_device_id", count_find_device |
| ) |
| for name in names: |
| dataframe[name].close() |
| |
| assert find_device_calls == 3 |
| assert len(dataframe._index._descriptor_cache) == 2 |
| assert len(dataframe._index.series_shards._cache) == 2 |
| |
| # d0 was the least recently used name and must be resolved again. |
| dataframe[names[0]].close() |
| assert find_device_calls == 4 |
| |
| |
| def test_reader_pool_enforces_open_file_cap(tmp_path, monkeypatch): |
| first = tmp_path / "part1.tsfile" |
| second = tmp_path / "part2.tsfile" |
| _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, 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 |
| assert dataframe._runtime.prepared.size == 2 |
| series.close() |
| |
| |
| def test_runtime_consume_concatenates_arrow_batches_without_scalar_reads(): |
| class _ArrowResult: |
| def __init__(self): |
| self._batches = iter( |
| [ |
| pa.table( |
| { |
| "time": pa.array([], type=pa.int64()), |
| "value": pa.array([], type=pa.float64()), |
| } |
| ), |
| pa.table( |
| { |
| "time": pa.array([1, 2], type=pa.int64()), |
| "value": pa.array([10.0, None], type=pa.float64()), |
| } |
| ), |
| pa.table( |
| { |
| "time": pa.array([3], type=pa.int64()), |
| "value": pa.array([30.0], type=pa.float64()), |
| } |
| ), |
| ] |
| ) |
| self.closed = False |
| |
| def __enter__(self): |
| return self |
| |
| def __exit__(self, *_): |
| self.closed = True |
| |
| def read_arrow_batch(self): |
| return next(self._batches, None) |
| |
| def next(self): |
| raise AssertionError("Runtime must not consume prepared rows one by one") |
| |
| result = _ArrowResult() |
| timestamps, values = RuntimeSeriesReader._consume(result) |
| |
| np.testing.assert_array_equal(timestamps, np.array([1, 2, 3], dtype=np.int64)) |
| np.testing.assert_allclose(values, np.array([10.0, np.nan, 30.0]), equal_nan=True) |
| assert result.closed |
| |
| |
| def test_prepared_query_reads_nullable_offset_window_in_arrow_batches(tmp_path): |
| source = tmp_path / "nullable.tsfile" |
| schema = TableSchema( |
| "weather", |
| [ |
| ColumnSchema("device", TSDataType.STRING, ColumnCategory.TAG), |
| ColumnSchema("value", TSDataType.DOUBLE, ColumnCategory.FIELD), |
| ], |
| ) |
| expected = np.arange(10, dtype=np.float64) |
| expected[2] = np.nan |
| expected[6] = np.nan |
| with TsFileTableWriter(str(source), schema) as writer: |
| writer.write_dataframe( |
| pd.DataFrame( |
| { |
| "time": np.arange(10, dtype=np.int64), |
| "device": ["d0"] * 10, |
| "value": expected, |
| } |
| ) |
| ) |
| |
| 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]) |
| with runtime.readers.acquire(0) as reader: |
| prepared = runtime.prepared.get(0, span[2], reader) |
| with reader.query_prepared(prepared, offset=1, limit=7) as result: |
| batches = [] |
| while True: |
| batch = result.read_arrow_batch() |
| if batch is None: |
| break |
| batches.append(batch) |
| with reader.query_prepared( |
| prepared, start_time=100, end_time=200 |
| ) as empty_result: |
| assert empty_result.read_arrow_batch() is None |
| |
| assert batches |
| table = pa.concat_tables(batches) |
| np.testing.assert_array_equal( |
| table.column("time").to_numpy(), np.arange(1, 8, dtype=np.int64) |
| ) |
| np.testing.assert_allclose( |
| table.column("value").to_numpy(zero_copy_only=False), |
| expected[1:8], |
| equal_nan=True, |
| ) |
| |
| |
| 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, use_index=True) as dataframe: |
| runtime = dataframe._runtime |
| series = runtime.index.record(LOGICAL_SERIES, 0) |
| span = runtime.index.record(SERIES_FILE_SPAN, series[2]) |
| locator = list(runtime.prepared._locator_tuple(0, span[2])) |
| 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) |
| |
| 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) |
| |
| |
| 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, use_index=True) 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"): |
| with pool.acquire(0): |
| pass |
| |
| |
| 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, use_index=True) as dataframe: |
| runtime = DatasetRuntime(str(dataframe._runtime.index.path), query_workers=1) |
| lease = runtime.lease() |
| entered = threading.Event() |
| release = threading.Event() |
| query_done = threading.Event() |
| |
| def run_query(): |
| with lease.query_lease(): |
| entered.set() |
| assert release.wait(timeout=2) |
| query_done.set() |
| |
| query_thread = threading.Thread(target=run_query) |
| query_thread.start() |
| assert entered.wait(timeout=2) |
| |
| close_done = threading.Event() |
| |
| def close_lease(): |
| lease.close() |
| close_done.set() |
| |
| close_thread = threading.Thread(target=close_lease) |
| close_thread.start() |
| assert not close_done.wait(timeout=0.05) |
| |
| release.set() |
| query_thread.join(timeout=2) |
| close_thread.join(timeout=2) |
| assert query_done.is_set() |
| assert close_done.is_set() |