piker/tests/test_ldshm.py

236 lines
6.0 KiB
Python

'''
Shared-memory persistence regressions.
'''
from collections.abc import (
AsyncIterator,
Iterator,
)
from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import numpy as np
import polars as pl
import pytest
from piker import tsp
from piker.storage import cli as storage_cli
from piker.storage.cli import (
_shm_period_and_invalid_count,
ldshm,
)
def test_ldshm_detects_nulls_without_skewing_period() -> None:
'''
Null SHM slots must stop persistence without skewing cadence.
A live QQQ buffer contained an interior run of zero-time rows.
``ldshm`` included them in period inference and then sent them to
NativeDB, which correctly rejected the replacement. Build a
60-second series with the same interior hole and enough
bars around the gap. Prove inspection counts every null while
inferring the published series' 60-second period; the
command uses that count to skip both persistence and SHM reload.
'''
times = np.array([
60,
120,
0,
0,
300,
360,
420,
])
period_s, invalid_count = _shm_period_and_invalid_count(times)
assert period_s == 60
assert invalid_count == 2
def test_ldshm_period_is_an_observed_step() -> None:
'''
Sparse timestamps must not invent a storage timeframe.
The arithmetic median of two steps can produce a cadence that
occurs nowhere in the source series. Such a value can select the
wrong NativeDB timeframe and hide the gap. Arrange tied 60 and
120-second steps and prove the deterministic tie-break chooses
the smaller observed period.
'''
times = np.array([60, 120, 240])
period_s, invalid_count = _shm_period_and_invalid_count(times)
assert period_s == 60
assert invalid_count == 0
def test_ldshm_counts_every_invalid_timestamp() -> None:
'''
Corrupt timestamps stop persistence just like null SHM slots.
Exact zero denotes an unpublished slot, while negative, NaN, and
infinite values indicate corruption. NativeDB rejects them all,
but allowing a replacement attempt would still abort ``ldshm``.
Mix each invalid class into a regular series and prove the same
snapshot inspection counts all four while preserving cadence.
'''
times = np.array([
60,
120,
0,
-1,
np.nan,
np.inf,
180,
])
period_s, invalid_count = _shm_period_and_invalid_count(times)
assert period_s == 60
assert invalid_count == 4
def test_ldshm_invalid_snapshot_never_reaches_storage(
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
Invalid snapshots stop before analyzers and storage mutation.
The original QQQ failure passed zero-time rows through dedupe and
into NativeDB. An intermediate fix called null-segment analysis
before its guard, allowing corrupt indexes to abort the command.
Supply one immutable Polars snapshot containing that combination,
then replace dedupe and writes with failing spies. Completing the
command proves its guard runs first, preventing persistence or
SHM reload without relying on task timing.
'''
shm_df = pl.DataFrame({
'index': [100, 500, 104],
'time': [60, 0, 180],
})
@asynccontextmanager
async def open_runtime(
*args: Any,
**kwargs: Any,
) -> AsyncIterator[None]:
'''
Yield a runtime-free command context.
'''
yield
async def fail_write(
*args: Any,
**kwargs: Any,
) -> None:
'''
Fail if invalid SHM reaches NativeDB.
'''
raise AssertionError('invalid SHM reached NativeDB')
client = SimpleNamespace(write_ohlcv=fail_write)
@asynccontextmanager
async def open_storage(
*args: Any,
**kwargs: Any,
) -> AsyncIterator[tuple[SimpleNamespace, SimpleNamespace]]:
'''
Yield storage objects with a failing writer.
'''
yield SimpleNamespace(), client
@asynccontextmanager
async def open_annotations(
*args: Any,
**kwargs: Any,
) -> AsyncIterator[SimpleNamespace]:
'''
Yield an inert annotation controller.
'''
yield SimpleNamespace()
def iter_shms(
fqme: str,
) -> Iterator[tuple[Path, SimpleNamespace, pl.DataFrame]]:
'''
Yield the single captured invalid snapshot.
'''
yield Path('qqq.rt'), SimpleNamespace(), shm_df
def dedupe(*args: Any, **kwargs: Any) -> None:
'''
Fail if invalid SHM reaches deduplication.
'''
raise AssertionError('invalid SHM reached dedupe')
async def pause() -> None:
'''
Replace the command's final interactive pause.
'''
monkeypatch.setattr(
storage_cli,
'open_piker_runtime',
open_runtime,
)
monkeypatch.setattr(
storage_cli,
'open_storage_client',
open_storage,
)
monkeypatch.setattr(tsp, 'iter_dfs_from_shms', iter_shms)
monkeypatch.setattr(tsp, 'dedupe_ohlcv_smart', dedupe)
monkeypatch.setattr(storage_cli.tractor, 'pause', pause)
from piker.ui import _remote_ctl
monkeypatch.setattr(
_remote_ctl,
'open_annot_ctl',
open_annotations,
)
ldshm('qqq.nasdaq.ib')
@pytest.mark.parametrize(
'times',
[
np.array([0, 0]),
np.array([60]),
np.array([60, 60]),
],
)
def test_ldshm_skips_frame_without_positive_cadence(
times: np.ndarray,
) -> None:
'''
Degenerate buffers are skipped before indexing or persistence.
The old inference indexed final timestamps and left its median
undefined for short buffers. Fully unpublished, one-row, and
duplicate-only frames could therefore fail before the command's
guard. Exercise each shape and prove inspection
returns no cadence, which directs ``ldshm`` to skip the buffer.
'''
period_s, _ = _shm_period_and_invalid_count(times)
assert period_s is None