blob: 8872f8cc8fce162dd935e132329fc12df9d1ac0e [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 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)