blob: 91761a11a45f7b7292e89a0da125ca2811196459 [file]
# 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()