blob: f3ededd7a430308a2f69fad065486fb5c24a7e72 [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.
#
import json
import os
import pandas as pd
import pytest
from tsfile import (
ColumnCategory,
ColumnSchema,
TSDataType,
TableSchema,
TsFileDataFrame,
TsFileTableWriter,
)
from tsfile.dataset import reader as reader_module
from tsfile.dataset.cache import (
catalog_from_dict,
catalog_to_dict,
extract_catalog,
manifest_path,
read_manifest,
)
def _write_weather_file(path, start=0):
schema = TableSchema(
"weather",
[
ColumnSchema("device", TSDataType.STRING, ColumnCategory.TAG),
ColumnSchema("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
ColumnSchema("humidity", TSDataType.DOUBLE, ColumnCategory.FIELD),
],
)
df = pd.DataFrame(
{
"time": [start, start + 1, start + 2],
"device": ["device_a", "device_a", "device_a"],
"temperature": [20.0, 21.5, 23.0],
"humidity": [50.0, 52.0, 55.0],
}
)
with TsFileTableWriter(str(path), schema) as writer:
writer.write_dataframe(df)
def test_catalog_round_trip(tmp_path):
path = tmp_path / "round_trip.tsfile"
_write_weather_file(path)
df = TsFileDataFrame(str(path), show_progress=False, cache="off")
reader = next(iter(df._readers.values()))
catalog = reader.catalog
rebuilt = catalog_from_dict(catalog_to_dict(catalog))
assert [t.table_name for t in rebuilt.table_entries] == [
t.table_name for t in catalog.table_entries
]
assert [t.field_columns for t in rebuilt.table_entries] == [
t.field_columns for t in catalog.table_entries
]
assert [t.tag_columns for t in rebuilt.table_entries] == [
t.tag_columns for t in catalog.table_entries
]
assert [t.tag_types for t in rebuilt.table_entries] == [
t.tag_types for t in catalog.table_entries
]
assert [
(d.table_id, d.tag_values, d.min_time, d.max_time)
for d in rebuilt.device_entries
] == [
(d.table_id, d.tag_values, d.min_time, d.max_time)
for d in catalog.device_entries
]
assert rebuilt.series_stats_by_ref == catalog.series_stats_by_ref
df.close()
def test_manifest_path_uses_common_parent(tmp_path):
paths = [str(tmp_path / f"shard_{i}.tsfile") for i in range(3)]
assert manifest_path(paths) == str(tmp_path / "tsfile_dataset.metacache.json")
def test_manifest_path_falls_back_for_single_file(tmp_path):
path = tmp_path / "single.tsfile"
_write_weather_file(path)
expected = str(tmp_path / "tsfile_dataset.metacache.json")
assert manifest_path([str(path)]) == expected
def test_cache_auto_hit_skips_native_metadata(tmp_path, monkeypatch):
path = tmp_path / "cache_hit.tsfile"
_write_weather_file(path)
TsFileDataFrame(str(path), show_progress=False, cache="auto").close()
assert os.path.exists(manifest_path([str(path)]))
calls = {"count": 0}
original = reader_module.TsFileSeriesReader._cache_metadata_table_model
def spy(self):
calls["count"] += 1
return original(self)
monkeypatch.setattr(
reader_module.TsFileSeriesReader, "_cache_metadata_table_model", spy
)
df_hot = TsFileDataFrame(str(path), show_progress=False, cache="auto")
try:
assert calls["count"] == 0
assert df_hot.list_timeseries() == [
"weather.device_a.temperature",
"weather.device_a.humidity",
]
finally:
df_hot.close()
def test_cache_invalidated_by_mtime_change(tmp_path):
path = tmp_path / "mtime.tsfile"
_write_weather_file(path)
TsFileDataFrame(str(path), show_progress=False, cache="auto").close()
shards = read_manifest(manifest_path([str(path)]))
assert extract_catalog(shards, str(path)) is not None
stat = os.stat(str(path))
os.utime(str(path), ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000))
assert extract_catalog(shards, str(path)) is None
def test_cache_invalidated_by_size_change(tmp_path):
path = tmp_path / "size.tsfile"
_write_weather_file(path)
TsFileDataFrame(str(path), show_progress=False, cache="auto").close()
mp = manifest_path([str(path)])
with open(mp, "r") as fh:
payload = json.load(fh)
payload["shards"][str(path)]["size"] += 1
with open(mp, "w") as fh:
json.dump(payload, fh)
assert extract_catalog(read_manifest(mp), str(path)) is None
def test_cache_off_does_not_write(tmp_path):
path = tmp_path / "cache_off.tsfile"
_write_weather_file(path)
df = TsFileDataFrame(str(path), show_progress=False, cache="off")
df.close()
assert not os.path.exists(manifest_path([str(path)]))
def test_cache_rebuild_overwrites_stale(tmp_path):
path = tmp_path / "rebuild.tsfile"
_write_weather_file(path)
mp = manifest_path([str(path)])
with open(mp, "w") as fh:
fh.write("not json")
df = TsFileDataFrame(str(path), show_progress=False, cache="rebuild")
try:
shards = read_manifest(mp)
assert shards is not None
assert extract_catalog(shards, str(path)) is not None
assert df.list_timeseries()
finally:
df.close()
def test_invalid_cache_mode_rejected(tmp_path):
path = tmp_path / "bad_mode.tsfile"
_write_weather_file(path)
with pytest.raises(ValueError):
TsFileDataFrame(str(path), show_progress=False, cache="bogus")
def test_invalid_max_open_files_rejected(tmp_path):
path = tmp_path / "bad_cap.tsfile"
_write_weather_file(path)
with pytest.raises(ValueError):
TsFileDataFrame(str(path), show_progress=False, max_open_files=0)
def test_single_manifest_for_multiple_shards(tmp_path):
paths = [tmp_path / f"shard_{i}.tsfile" for i in range(3)]
for i, p in enumerate(paths):
_write_weather_file(p, start=i * 100)
str_paths = [str(p) for p in paths]
mp = manifest_path(str_paths)
df = TsFileDataFrame(str_paths, show_progress=False, cache="auto")
df.close()
assert os.path.exists(mp)
shards = read_manifest(mp)
assert set(shards.keys()) == set(str_paths)
for p in paths:
assert not os.path.exists(str(p) + ".metacache.json")
def test_adding_shard_keeps_existing_entries(tmp_path):
initial = [tmp_path / "shard_0.tsfile", tmp_path / "shard_1.tsfile"]
for i, p in enumerate(initial):
_write_weather_file(p, start=i * 100)
initial_paths = [str(p) for p in initial]
TsFileDataFrame(initial_paths, show_progress=False, cache="auto").close()
new_shard = tmp_path / "shard_2.tsfile"
_write_weather_file(new_shard, start=200)
expanded_paths = initial_paths + [str(new_shard)]
TsFileDataFrame(expanded_paths, show_progress=False, cache="auto").close()
shards = read_manifest(manifest_path(expanded_paths))
assert set(shards.keys()) == set(expanded_paths)
def test_manifest_skipped_on_full_hit(tmp_path):
path = tmp_path / "no_rewrite.tsfile"
_write_weather_file(path)
TsFileDataFrame(str(path), show_progress=False, cache="auto").close()
mp = manifest_path([str(path)])
before = os.stat(mp).st_mtime_ns
TsFileDataFrame(str(path), show_progress=False, cache="auto").close()
after = os.stat(mp).st_mtime_ns
assert before == after
def test_lru_evicts_older_reader_when_over_cap(tmp_path):
paths = [tmp_path / f"lru_{i}.tsfile" for i in range(3)]
for i, p in enumerate(paths):
_write_weather_file(p, start=i * 100)
str_paths = [str(p) for p in paths]
df = TsFileDataFrame(str_paths, show_progress=False, cache="auto", max_open_files=2)
try:
# After eager init with cap=2, the LRU shard's native handle is closed
# but its Python wrapper + catalog stay valid.
first = df._readers[str_paths[0]]
second = df._readers[str_paths[1]]
third = df._readers[str_paths[2]]
assert first._reader is None
assert second._reader is not None
assert third._reader is not None
assert df._fd_pool.capacity == 2
assert len(df._fd_pool) == 2
# Touching the evicted reader reopens its native handle and pushes the
# current LRU (`second`) out.
first._ensure_open()
assert first._reader is not None
assert third._reader is not None
assert second._reader is None
assert len(df._fd_pool) == 2
finally:
df.close()
def test_lru_disabled_when_cap_exceeds_shard_count(tmp_path):
paths = [tmp_path / f"all_open_{i}.tsfile" for i in range(3)]
for i, p in enumerate(paths):
_write_weather_file(p, start=i * 100)
df = TsFileDataFrame(
[str(p) for p in paths],
show_progress=False,
cache="auto",
max_open_files=16,
)
try:
for p in paths:
assert df._readers[str(p)]._reader is not None
assert len(df._fd_pool) == 3
finally:
df.close()
def test_lru_close_all_drains_pool(tmp_path):
path = tmp_path / "drain.tsfile"
_write_weather_file(path)
df = TsFileDataFrame(str(path), show_progress=False, cache="auto")
reader = next(iter(df._readers.values()))
assert reader._reader is not None
df.close()
assert reader._reader is None
def test_manifest_uses_columnar_series_stats(tmp_path):
path = tmp_path / "columnar.tsfile"
_write_weather_file(path)
TsFileDataFrame(str(path), show_progress=False, cache="auto").close()
with open(manifest_path([str(path)]), "r") as fh:
payload = json.load(fh)
series_stats = payload["shards"][str(path)]["catalog"]["series_stats"]
assert isinstance(series_stats, dict)
expected_columns = {
"device_index",
"field_index",
"length",
"min_time",
"max_time",
"timeline_length",
"timeline_min_time",
"timeline_max_time",
}
assert set(series_stats.keys()) == expected_columns
lengths = {len(series_stats[col]) for col in expected_columns}
assert len(lengths) == 1
def test_manifest_version_bump_invalidates_old_payload(tmp_path):
path = tmp_path / "old_version.tsfile"
_write_weather_file(path)
TsFileDataFrame(str(path), show_progress=False, cache="auto").close()
mp = manifest_path([str(path)])
# Forge a manifest that uses the previous schema (cache_version=2).
with open(mp, "r") as fh:
payload = json.load(fh)
payload["cache_version"] = 2
with open(mp, "w") as fh:
json.dump(payload, fh)
assert read_manifest(mp) is None