| # 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) |