| # 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. |
| # |
| |
| """Reader input sources: paths, file objects, ownership, and read failures.""" |
| |
| import gc |
| import io |
| import os |
| import subprocess |
| import sys |
| import weakref |
| from pathlib import Path |
| from typing import Optional |
| |
| import pytest |
| import numpy as np |
| |
| from tsfile import ( |
| Field, |
| RowRecord, |
| TSDataType, |
| TimeseriesSchema, |
| TsFileReader, |
| TsFileWriter, |
| ) |
| from tsfile.exceptions import FileOpenError, FileReadError |
| from tsfile.tag_filter import BetweenTagFilter, ComparisonTagFilter |
| |
| RESOURCES = Path(__file__).parent / "resources" |
| |
| |
| class TrackingBytesIO(io.BytesIO): |
| def __init__(self, data: bytes, max_chunk_size: Optional[int] = None): |
| super().__init__(data) |
| self.max_chunk_size = max_chunk_size |
| self.read_sizes = [] |
| self.close_calls = 0 |
| |
| def read(self, size=-1): |
| if size < 0: |
| raise AssertionError("TsFileReader must use explicitly sized reads") |
| self.read_sizes.append(size) |
| if self.max_chunk_size is not None: |
| size = min(size, self.max_chunk_size) |
| return super().read(size) |
| |
| def close(self): |
| self.close_calls += 1 |
| super().close() |
| |
| |
| def collect_table_rows(source): |
| with TsFileReader(source) as reader: |
| result = reader.query_table("test", ["s0", "s2"]) |
| try: |
| rows = [] |
| while result.next(): |
| rows.append(tuple(result.get_value_by_index(i) for i in range(1, 4))) |
| return rows |
| finally: |
| result.close() |
| |
| |
| def collect_tree_rows(source): |
| with TsFileReader(source) as reader: |
| result = reader.query_timeseries( |
| "root.ln.wf01.wt01", |
| ["temperature", "status"], |
| 0, |
| (1 << 63) - 1, |
| ) |
| try: |
| rows = [] |
| while result.next(): |
| rows.append(tuple(result.get_value_by_index(i) for i in range(1, 4))) |
| return rows |
| finally: |
| result.close() |
| |
| |
| class CustomPath: |
| def __init__(self, path): |
| self.path = path |
| |
| def __fspath__(self): |
| return self.path |
| |
| |
| @pytest.mark.parametrize( |
| "make_source", |
| [str, Path, os.fsencode, CustomPath, lambda p: CustomPath(os.fsencode(p)), np.str_], |
| ids=["str", "pathlib", "bytes", "pathlike-str", "pathlike-bytes", "numpy-str"], |
| ) |
| def test_path_sources_match_string_path(make_source): |
| path = RESOURCES / "simple_table_t1.tsfile" |
| expected = collect_table_rows(str(path)) |
| assert expected |
| assert collect_table_rows(make_source(str(path))) == expected |
| |
| |
| @pytest.mark.skipif(os.name != "posix", reason="POSIX filesystem byte paths") |
| @pytest.mark.parametrize( |
| "make_source", |
| [lambda p: p, CustomPath, lambda p: Path(os.fsdecode(p))], |
| ids=["bytes", "pathlike-bytes", "pathlib"], |
| ) |
| def test_non_utf8_missing_path_reaches_native_open(tmp_path, make_source): |
| path = os.fsencode(tmp_path) + b"/missing-\xff.tsfile" |
| with pytest.raises(FileOpenError): |
| TsFileReader(make_source(path)) |
| |
| |
| @pytest.mark.skipif( |
| not sys.platform.startswith("linux"), |
| reason="Linux filenames permit arbitrary non-NUL bytes; macOS rejects them", |
| ) |
| def test_non_utf8_filesystem_path_is_preserved(tmp_path): |
| fixture = RESOURCES / "simple_table_t1.tsfile" |
| path = os.fsencode(tmp_path) + b"/sample-\xff.tsfile" |
| with open(path, "wb") as output: |
| output.write(fixture.read_bytes()) |
| assert collect_table_rows(path) == collect_table_rows(str(fixture)) |
| |
| |
| def test_file_object_table_query_handles_short_reads_and_preserves_cursor(): |
| path = RESOURCES / "simple_table_t1.tsfile" |
| expected = collect_table_rows(str(path)) |
| source = TrackingBytesIO(path.read_bytes(), max_chunk_size=7) |
| source.seek(11) |
| |
| actual = collect_table_rows(source) |
| |
| assert actual == expected |
| assert source.tell() == 11 |
| assert source.read_sizes |
| assert source.close_calls == 0 |
| |
| |
| def test_file_object_initializes_native_runtime_in_fresh_process(): |
| path = RESOURCES / "simple_table_t1.tsfile" |
| script = """ |
| import io |
| import sys |
| from pathlib import Path |
| |
| from tsfile import TsFileReader |
| |
| source = io.BytesIO(Path(sys.argv[1]).read_bytes()) |
| source.seek(11) |
| with TsFileReader(source) as reader: |
| result = reader.query_table("test", ["s0"]) |
| try: |
| assert result.next() |
| assert result.get_value_by_index(1) == 1760106020000 |
| assert result.get_value_by_index(2) == "a" |
| finally: |
| result.close() |
| assert source.tell() == 11 |
| """ |
| |
| completed = subprocess.run( |
| [sys.executable, "-c", script, str(path)], |
| cwd=Path(__file__).parents[1], |
| capture_output=True, |
| text=True, |
| ) |
| |
| assert completed.returncode == 0, completed.stderr |
| |
| |
| def test_file_object_multi_field_query_returns_expected_row(): |
| path = RESOURCES / "simple_table_t1.tsfile" |
| source = io.BytesIO(path.read_bytes()) |
| source.seek(19) |
| |
| with TsFileReader(source) as reader: |
| result = reader.query_table("test", ["s2", "s3"]) |
| try: |
| assert result.next() |
| assert result.get_value_by_index(1) == 1760106020000 |
| assert result.get_value_by_index(2) == 1010 |
| assert result.get_value_by_index(3) == 2.0 |
| finally: |
| result.close() |
| |
| assert source.tell() == 19 |
| |
| |
| def test_reader_retains_source_until_close_then_releases_it(): |
| path = RESOURCES / "simple_table_t1.tsfile" |
| source = TrackingBytesIO(path.read_bytes()) |
| source_ref = weakref.ref(source) |
| reader = TsFileReader(source) |
| |
| del source |
| gc.collect() |
| assert source_ref() is not None |
| |
| reader.close() |
| gc.collect() |
| assert source_ref() is None |
| |
| |
| def test_file_object_tree_query_handles_short_reads(): |
| path = RESOURCES / "simple_tree.tsfile" |
| expected = collect_tree_rows(str(path)) |
| source = TrackingBytesIO(path.read_bytes(), max_chunk_size=5) |
| |
| assert collect_tree_rows(source) == expected |
| |
| |
| def test_source_without_file_methods_is_rejected(): |
| with pytest.raises(TypeError, match="seekable binary file object"): |
| TsFileReader(object()) |
| |
| |
| @pytest.mark.parametrize( |
| "failure,fail_restore", |
| [ |
| ("initial_tell", False), |
| ("seek_end", False), |
| ("size_tell", False), |
| ("restore", True), |
| ("seek_end", True), |
| ("size_tell", True), |
| ], |
| ) |
| def test_size_probe_errors_map_to_file_open_error(failure, fail_restore): |
| class FailingProbeSource(TrackingBytesIO): |
| tell_calls = 0 |
| |
| def __init__(self, data): |
| super().__init__(data) |
| self.seek_calls = [] |
| |
| def tell(self): |
| self.tell_calls += 1 |
| if (failure == "initial_tell" and self.tell_calls == 1) or ( |
| failure == "size_tell" and self.tell_calls == 2 |
| ): |
| raise OSError("size probe tell failed") |
| return super().tell() |
| |
| def seek(self, offset, whence=os.SEEK_SET): |
| self.seek_calls.append((offset, whence)) |
| if whence == os.SEEK_SET and fail_restore: |
| raise RuntimeError("cursor restoration failed") |
| position = super().seek(offset, whence) |
| if whence == os.SEEK_END and failure == "seek_end": |
| # A source may move its cursor before reporting failure. |
| raise OSError("size probe seek failed") |
| return position |
| |
| data = (RESOURCES / "simple_table_t1.tsfile").read_bytes() |
| source = FailingProbeSource(data) |
| io.BytesIO.seek(source, 13) |
| |
| with pytest.raises(FileOpenError): |
| TsFileReader(source) |
| |
| assert not source.closed |
| assert source.close_calls == 0 |
| assert source.read_sizes == [] |
| if failure == "initial_tell": |
| assert source.seek_calls == [] |
| else: |
| assert source.seek_calls == [(0, os.SEEK_END), (13, os.SEEK_SET)] |
| assert io.BytesIO.tell(source) == (len(data) if fail_restore else 13) |
| |
| |
| def test_source_read_error_during_open_preserves_cursor(): |
| path = RESOURCES / "simple_table_t1.tsfile" |
| |
| class FailingBytesIO(TrackingBytesIO): |
| fail_reads = False |
| |
| def read(self, size=-1): |
| if self.fail_reads: |
| raise OSError("remote read failed") |
| return super().read(size) |
| |
| source = FailingBytesIO(path.read_bytes()) |
| source.seek(13) |
| source.fail_reads = True |
| |
| with pytest.raises(FileReadError): |
| TsFileReader(source) |
| |
| assert source.tell() == 13 |
| assert source.close_calls == 0 |
| |
| |
| def test_source_read_error_during_query_preserves_cursor_and_source(): |
| path = RESOURCES / "simple_table_t1.tsfile" |
| |
| class FailingBytesIO(TrackingBytesIO): |
| fail_reads = False |
| |
| def read(self, size=-1): |
| if self.fail_reads: |
| raise OSError("remote read failed") |
| return super().read(size) |
| |
| source = FailingBytesIO(path.read_bytes()) |
| source.seek(17) |
| |
| with TsFileReader(source) as reader: |
| source.fail_reads = True |
| with pytest.raises(FileReadError): |
| result = reader.query_table("test", ["s0"]) |
| try: |
| result.next() |
| finally: |
| result.close() |
| |
| assert source.tell() == 17 |
| assert source.close_calls == 0 |
| |
| |
| @pytest.mark.parametrize("measurements", [["temperature"], ["temperature", "status"]]) |
| @pytest.mark.parametrize("stage", ["initial_block", "next_block"]) |
| def test_tree_query_propagates_data_block_read_errors(tmp_path, measurements, stage): |
| class FailingBytesIO(TrackingBytesIO): |
| read_calls = 0 |
| fail_at = None |
| failed = False |
| |
| def read(self, size=-1): |
| self.read_calls += 1 |
| if self.fail_at is not None and self.read_calls >= self.fail_at: |
| self.failed = True |
| raise OSError("data block read failed") |
| return super().read(size) |
| |
| path = tmp_path / "multiple_chunks.tsfile" |
| device = "root.sg.device" |
| with TsFileWriter(str(path)) as writer: |
| for name in measurements: |
| writer.register_timeseries(device, TimeseriesSchema(name, TSDataType.INT64)) |
| for chunk in range(2): |
| for timestamp in range(chunk * 100, (chunk + 1) * 100): |
| writer.write_row_record( |
| RowRecord( |
| device, |
| timestamp, |
| [ |
| Field(name, timestamp, TSDataType.INT64) |
| for name in measurements |
| ], |
| ) |
| ) |
| writer.flush() |
| data = path.read_bytes() |
| |
| def query(reader): |
| return reader.query_timeseries(device, measurements, 0, (1 << 63) - 1) |
| |
| # Locate the initial and subsequent block reads without fixing their call |
| # numbers: opening and metadata reads may change as the reader evolves. |
| baseline = FailingBytesIO(data) |
| with TsFileReader(baseline) as reader: |
| with query(reader) as result: |
| initial_block_read = baseline.read_calls |
| rows = 0 |
| while result.next(): |
| rows += 1 |
| assert rows == 200 |
| assert baseline.read_calls > initial_block_read |
| |
| source = FailingBytesIO(data) |
| source.seek(17) |
| with TsFileReader(source) as reader: |
| if stage == "initial_block": |
| source.fail_at = initial_block_read |
| with pytest.raises(FileReadError): |
| with query(reader): |
| pass |
| else: |
| with query(reader) as result: |
| source.fail_at = source.read_calls + 1 |
| with pytest.raises(FileReadError): |
| while result.next(): |
| pass |
| # A failed result must not become a successful EOF on retry. |
| source.fail_at = None |
| with pytest.raises(FileReadError): |
| result.next() |
| assert source.failed |
| assert source.tell() == 17 |
| assert source.close_calls == 0 |
| |
| |
| @pytest.mark.parametrize( |
| "method", |
| [ |
| "get_all_devices", |
| "get_all_table_schemas", |
| "get_table_schema", |
| "get_all_timeseries_schemas", |
| "get_timeseries_metadata", |
| ], |
| ) |
| def test_metadata_read_failure_is_not_an_empty_result(method): |
| class FailingBytesIO(TrackingBytesIO): |
| fail_reads = False |
| failed = False |
| |
| def read(self, size=-1): |
| if self.fail_reads: |
| self.failed = True |
| raise OSError("metadata read failed") |
| return super().read(size) |
| |
| source = FailingBytesIO((RESOURCES / "simple_table_t1.tsfile").read_bytes()) |
| source.seek(17) |
| with TsFileReader(source) as reader: |
| source.fail_reads = True |
| args = ("test",) if method == "get_table_schema" else () |
| with pytest.raises(FileReadError): |
| getattr(reader, method)(*args) |
| source.fail_reads = False |
| assert getattr(reader, method)(*args) is not None |
| assert source.failed |
| assert source.tell() == 17 |
| assert source.close_calls == 0 |
| |
| |
| def test_device_index_read_failure_does_not_return_partial_devices(tmp_path): |
| class FailingBytesIO(TrackingBytesIO): |
| fail_index_reads = False |
| index_reads = 0 |
| failed = False |
| |
| def read(self, size=-1): |
| if self.fail_index_reads: |
| self.index_reads += 1 |
| if self.index_reads == 2: |
| self.failed = True |
| raise OSError("device index read failed") |
| return super().read(size) |
| |
| # More than two default index nodes (256 children each), so a failure in |
| # the second node must not be hidden by a successful read of the third. |
| path = tmp_path / "device_index.tsfile" |
| with TsFileWriter(str(path)) as writer: |
| for index in range(513): |
| device = f"root.sg.d{index:04d}" |
| writer.register_timeseries(device, TimeseriesSchema("s", TSDataType.INT64)) |
| writer.write_row_record( |
| RowRecord(device, 0, [Field("s", index, TSDataType.INT64)]) |
| ) |
| source = FailingBytesIO(path.read_bytes()) |
| source.seek(17) |
| with TsFileReader(source) as reader: |
| assert len(reader.get_all_devices()) == 513 |
| source.fail_index_reads = True |
| with pytest.raises(FileReadError): |
| reader.get_all_devices() |
| assert source.failed |
| assert source.tell() == 17 |
| assert source.close_calls == 0 |
| |
| |
| @pytest.mark.parametrize( |
| "method,columns,offset", |
| [ |
| ("query_table", ["s0", "s2"], 0), |
| ("query_table_by_row", ["s0", "s2"], 0), |
| ("query_table", ["s0", "s2", "s3"], 0), |
| ("query_table_by_row", ["s0", "s2", "s3"], 0), |
| ("query_table_by_row", ["s0", "s2"], 1), |
| ("query_table_by_row", ["s0", "s2", "s3"], 1), |
| ], |
| ) |
| @pytest.mark.parametrize("tag_kind", [None, "eq", "between"]) |
| @pytest.mark.parametrize("batch_size", [0, 16]) |
| @pytest.mark.parametrize("short_read", [False, True]) |
| @pytest.mark.parametrize("persistent", [False, True]) |
| def test_table_queries_propagate_every_read_failure( |
| method, columns, offset, tag_kind, batch_size, short_read, persistent |
| ): |
| class FailingBytesIO(TrackingBytesIO): |
| read_calls = 0 |
| fail_at = None |
| failed = False |
| |
| def read(self, size=-1): |
| self.read_calls += 1 |
| if self.fail_at is not None and ( |
| self.read_calls >= self.fail_at |
| if persistent |
| else self.read_calls == self.fail_at |
| ): |
| self.failed = True |
| if short_read: |
| return b"" |
| raise OSError("table read failed") |
| return super().read(size) |
| |
| data = (RESOURCES / "simple_table_t1.tsfile").read_bytes() |
| |
| def query(reader): |
| tag_filter = None |
| if tag_kind == "eq": |
| tag_filter = ComparisonTagFilter("s0", "a", ComparisonTagFilter.EQ) |
| elif tag_kind == "between": |
| tag_filter = BetweenTagFilter("s0", "a", "a") |
| kwargs = {"offset": offset} if method == "query_table_by_row" else {} |
| return getattr(reader, method)( |
| "test", columns, tag_filter=tag_filter, batch_size=batch_size, **kwargs |
| ) |
| |
| def consume(result): |
| rows = 0 |
| if batch_size: |
| while True: |
| batch = result.read_arrow_record_batch() |
| if batch is None: |
| break |
| rows += batch.num_rows |
| else: |
| while result.next(): |
| rows += 1 |
| return rows |
| |
| baseline = FailingBytesIO(data) |
| with TsFileReader(baseline) as reader: |
| open_reads = baseline.read_calls |
| with query(reader) as result: |
| assert consume(result) == (60 if tag_kind is None else 30) - offset |
| |
| # Sweep actual reads rather than hard-coding call numbers: metadata may |
| # be prefetched or cached differently as the implementation evolves. |
| for fail_at in range(open_reads + 1, baseline.read_calls + 1): |
| source = FailingBytesIO(data) |
| source.seek(17) |
| with TsFileReader(source) as reader: |
| source.fail_at = fail_at |
| result = None |
| try: |
| with pytest.raises(FileReadError): |
| result = query(reader) |
| consume(result) |
| assert source.failed, f"read {fail_at} was not reached" |
| if result is not None: |
| # Recovery of the source must not turn a failed result |
| # into successful EOF or resume it after skipped devices. |
| source.fail_at = None |
| with pytest.raises(FileReadError): |
| result.next() |
| if batch_size: |
| with pytest.raises(FileReadError): |
| result.read_arrow_record_batch() |
| finally: |
| if result is not None: |
| result.close() |
| assert source.tell() == 17 |
| assert not source.closed |
| assert source.close_calls == 0 |