Merge feat/core-logic into dev

This commit is contained in:
2026-09-28 19:04:52 +04:00
8 changed files with 612 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
"""Unit conversion utilities for latency data."""
from typing import Dict
import numpy as np
UNIT_FACTORS: Dict[str, float] = {
"ns": 1.0,
"us": 1e3,
"ms": 1e6,
"s": 1e9,
}
UNIT_LABELS: Dict[str, str] = {
"ns": "ns",
"us": "µs",
"ms": "ms",
"s": "s",
}
def to_unit(values_ns, unit: str) -> np.ndarray:
"""
Convert nanosecond values to the specified unit.
Args:
values_ns: Values in nanoseconds
unit: Target unit (ns, us, ms, s)
Returns:
Converted values as numpy array
"""
return np.asarray(values_ns, dtype=float) / UNIT_FACTORS[unit]
+88
View File
@@ -0,0 +1,88 @@
"""CSV data extraction module for fio latency data."""
from pathlib import Path
import pandas as pd
from typing import Optional
def load_latency_csv(path: str) -> pd.DataFrame:
"""
Parse wide-format fio JSON+ CSV into long-format DataFrame.
Transforms wide-format columns (e.g., read_clat_ns_count) into rows with
a metric identifier, making it easier to group and analyze by operation type.
Args:
path: Path to CSV file generated by fio_jsonplus_clat2csv
Returns:
DataFrame with columns: metric, latency_ns, count, cumulative, percentile
Raises:
ValueError: If CSV format is invalid or contains no valid data
"""
df = pd.read_csv(path, skipinitialspace=True)
df.columns = [str(c).strip() for c in df.columns]
# Drop trailing unnamed / empty columns
df = df.loc[:, ~df.columns.str.match(r"^Unnamed")]
df = df.loc[:, df.columns != ""]
nsec_col = df.columns[0]
if "nsec" not in nsec_col.lower():
raise ValueError(
f"expected first column to be 'nsec', got {nsec_col!r}"
)
# Coerce the nsec column to numeric
df[nsec_col] = pd.to_numeric(df[nsec_col], errors="coerce")
df = df.dropna(subset=[nsec_col])
# Discover metric triples: <metric>_count / _cumulative / _percentile
metrics = {}
for col in df.columns:
if col == nsec_col:
continue
for suffix in ("_count", "_cumulative", "_percentile"):
if col.endswith(suffix):
base = col[: -len(suffix)]
metrics.setdefault(base, {})[suffix[1:]] = col
if not metrics:
raise ValueError("no *_count columns found in CSV")
frames = []
for base, cols in metrics.items():
count_col = cols["count"]
# Coerce count to numeric
counts = pd.to_numeric(df[count_col], errors="coerce")
# Keep only rows where the count column actually has a number
mask = counts.notna()
if not mask.any():
continue
sub = pd.DataFrame({
"latency_ns": df.loc[mask, nsec_col].values,
"count": counts[mask].astype(int).values,
})
if "cumulative" in cols:
sub["cumulative"] = pd.to_numeric(
df.loc[mask, cols["cumulative"]], errors="coerce"
).values
if "percentile" in cols:
sub["percentile"] = pd.to_numeric(
df.loc[mask, cols["percentile"]], errors="coerce"
).values
# read_clat_ns -> read_clat
sub["metric"] = base.replace("_ns", "")
frames.append(sub)
if not frames:
raise ValueError("no metric had any numeric count data")
out = pd.concat(frames, ignore_index=True)
out = out[out["count"] > 0].reset_index(drop=True)
return out
+62
View File
@@ -0,0 +1,62 @@
"""Helper functions for formatting and display."""
from typing import List
import numpy as np
import pandas as pd
from matplotlib.ticker import FuncFormatter
from .converter import UNIT_LABELS
def human_count(x, _pos=None) -> str:
"""
Format large numbers with K/M/G suffixes for axis labels.
Args:
x: Numeric value to format
_pos: Matplotlib tick position (unused)
Returns:
Formatted string (e.g., "1.5M", "3.2G")
"""
if x >= 1e9:
return f"{x/1e9:.1f}G"
if x >= 1e6:
return f"{x/1e6:.1f}M"
if x >= 1e3:
return f"{x/1e3:.0f}k"
return f"{x:.0f}"
def percentile_table(df: pd.DataFrame, metric: str, unit: str, pcts: List[float]) -> str:
"""
Generate a formatted table showing latency values at specified percentiles.
Args:
df: Long-format DataFrame from load_latency_csv
metric: Metric name (e.g., 'read_clat')
unit: Display unit (ns, us, ms, s)
pcts: List of percentile values (e.g., [50, 90, 99])
Returns:
Formatted string table
"""
g = df[df["metric"] == metric].sort_values("latency_ns")
if g.empty:
return f"(no data for metric {metric})"
counts = g["count"].values.astype(float)
total = int(counts.sum())
cdf = np.cumsum(counts) / total * 100.0
lat = to_unit(g["latency_ns"].values, unit)
lines = [
f"\nMetric: {metric} (total I/Os: {total:,})",
f" {'percentile':>12} {'latency':>14}",
]
for p in pcts:
idx = min(np.searchsorted(cdf, p), len(lat) - 1)
lines.append(f" {p:>11.2f}% {lat[idx]:>12.3f} {UNIT_LABELS[unit]}")
return "\n".join(lines)
+164
View File
@@ -0,0 +1,164 @@
"""Tool orchestration module for running disk benchmarks."""
import asyncio
import subprocess
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Dict, Optional
class ToolStatus(Enum):
"""Status of a benchmark job."""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
@dataclass
class JobResult:
"""Result of a benchmark job."""
job_id: str
status: ToolStatus
output_path: Optional[Path] = None
error: Optional[str] = None
metadata: Dict = field(default_factory=dict)
class Orchestrator:
"""
Orchestrates disk benchmarking tools (fio, badblocks, etc.)
Provides async interfaces for running benchmarks and collecting results.
"""
def __init__(self, work_dir: Path = Path(".")):
self.work_dir = work_dir
self._jobs: Dict[str, JobResult] = {}
async def run_fio(
self,
device: str,
job_file: Optional[Path] = None,
output_format: str = "jsonplus",
**kwargs,
) -> Path:
"""
Run fio benchmark and return path to output file.
Args:
device: Block device path (e.g., '/dev/sda')
job_file: Optional fio job file
output_format: Output format (json, jsonplus, csv)
**kwargs: Additional fio options
Returns:
Path to output file
Raises:
subprocess.CalledProcessError: If fio command fails
"""
job_id = f"fio_{asyncio.get_event_loop().time()}"
output_path = self.work_dir / f"{job_id}.{output_format}"
cmd = ["fio", "--output-format=jsonplus"]
if job_file:
cmd.extend(["--filename", str(device)])
else:
cmd.extend(["--name=test", "--rw=randread", "--bs=4k"])
cmd.extend(["--output", str(output_path)])
cmd.extend([f"--output-format={output_format}"])
for k, v in kwargs.items():
cmd.extend([f"--{k}={v}"])
self._jobs[job_id] = JobResult(
job_id=job_id,
status=ToolStatus.RUNNING,
output_path=output_path,
)
try:
await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._jobs[job_id] = JobResult(
job_id=job_id,
status=ToolStatus.COMPLETED,
output_path=output_path,
)
return output_path
except subprocess.CalledProcessError as e:
self._jobs[job_id] = JobResult(
job_id=job_id,
status=ToolStatus.FAILED,
error=str(e),
)
raise
async def run_badblocks(
self,
device: str,
blocks_count: Optional[int] = None,
**kwargs,
) -> Path:
"""
Run badblocks scan on device.
Args:
device: Block device path
blocks_count: Number of blocks to scan
**kwargs: Additional badblocks options
Returns:
Path to badblocks output file
"""
job_id = f"badblocks_{asyncio.get_event_loop().time()}"
output_path = self.work_dir / f"{job_id}.txt"
cmd = ["badblocks", "-w", "-s", "-p", "0", "-t", "RANDOM", "-o"]
cmd.extend([str(output_path), str(device)])
if blocks_count:
cmd.extend(["-b", str(blocks_count)])
for k, v in kwargs.items():
cmd.extend([f"-{k}", str(v)])
self._jobs[job_id] = JobResult(
job_id=job_id,
status=ToolStatus.RUNNING,
output_path=output_path,
)
try:
await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._jobs[job_id] = JobResult(
job_id=job_id,
status=ToolStatus.COMPLETED,
output_path=output_path,
)
return output_path
except subprocess.CalledProcessError as e:
self._jobs[job_id] = JobResult(
job_id=job_id,
status=ToolStatus.FAILED,
error=str(e),
)
raise
def get_job_status(self, job_id: str) -> Optional[JobResult]:
"""Get status of a submitted job."""
return self._jobs.get(job_id)
def list_jobs(self) -> Dict[str, JobResult]:
"""List all jobs."""
return self._jobs.copy()
+68
View File
@@ -0,0 +1,68 @@
"""Tests for the unit conversion module."""
import numpy as np
import pytest
from disk_checker.core.converter import to_unit, UNIT_FACTORS, UNIT_LABELS
class TestToUnit:
"""Tests for to_unit function."""
def test_nanoseconds_to_microseconds(self):
"""Test conversion from ns to us."""
values = np.array([1000, 2000, 3000])
result = to_unit(values, "us")
expected = np.array([1.0, 2.0, 3.0])
np.testing.assert_array_equal(result, expected)
def test_nanoseconds_to_milliseconds(self):
"""Test conversion from ns to ms."""
values = np.array([1000000, 2000000])
result = to_unit(values, "ms")
expected = np.array([1.0, 2.0])
np.testing.assert_array_equal(result, expected)
def test_nanoseconds_to_seconds(self):
"""Test conversion from ns to s."""
values = np.array([1000000000, 2000000000])
result = to_unit(values, "s")
expected = np.array([1.0, 2.0])
np.testing.assert_array_equal(result, expected)
def test_same_unit_returns_same(self):
"""Test conversion to same unit."""
values = np.array([100, 200, 300])
result = to_unit(values, "ns")
np.testing.assert_array_equal(result, values)
def test_empty_array(self):
"""Test conversion with empty array."""
values = np.array([])
result = to_unit(values, "us")
assert len(result) == 0
def test_single_value(self):
"""Test conversion with single value."""
result = to_unit(1000, "us")
assert result == 1.0
class TestConstants:
"""Tests for module constants."""
def test_unit_factors(self):
"""Test UNIT_FACTORS contains all expected units."""
assert set(UNIT_FACTORS.keys()) == {"ns", "us", "ms", "s"}
assert UNIT_FACTORS["ns"] == 1.0
assert UNIT_FACTORS["us"] == 1e3
assert UNIT_FACTORS["ms"] == 1e6
assert UNIT_FACTORS["s"] == 1e9
def test_unit_labels(self):
"""Test UNIT_LABELS contains all expected units."""
assert set(UNIT_LABELS.keys()) == {"ns", "us", "ms", "s"}
assert UNIT_LABELS["ns"] == "ns"
assert UNIT_LABELS["us"] == "µs"
assert UNIT_LABELS["ms"] == "ms"
assert UNIT_LABELS["s"] == "s"
+72
View File
@@ -0,0 +1,72 @@
"""Tests for the data extraction module."""
import pytest
import pandas as pd
from pathlib import Path
from disk_checker.core.extractor import load_latency_csv
class TestLoadLatencyCsv:
"""Tests for load_latency_csv function."""
def test_load_valid_csv(self, sample_csv_path):
"""Test loading a valid fio CSV file."""
df = load_latency_csv(str(sample_csv_path))
assert not df.empty
assert "metric" in df.columns
assert "latency_ns" in df.columns
assert "count" in df.columns
def test_returns_long_format(self, sample_csv_path):
"""Test that output is in long format with metric column."""
df = load_latency_csv(str(sample_csv_path))
metrics = df["metric"].unique()
assert "read_clat" in metrics
def test_empty_csv_raises(self, empty_csv_path):
"""Test that empty CSV raises ValueError."""
with pytest.raises(ValueError, match="no metric had any numeric count data"):
load_latency_csv(str(empty_csv_path))
def test_invalid_format_raises(self, invalid_csv_path):
"""Test that invalid CSV format raises ValueError."""
with pytest.raises(ValueError):
load_latency_csv(str(invalid_csv_path))
def test_missing_nsec_column_raises(self, tmp_path):
"""Test that missing nsec column raises ValueError."""
csv_content = "col1,col2\n1,2\n"
path = tmp_path / "invalid.csv"
path.write_text(csv_content)
with pytest.raises(ValueError, match="expected first column to be 'nsec'"):
load_latency_csv(str(path))
@pytest.fixture
def sample_csv_path(tmp_path):
"""Create a sample CSV file for testing."""
csv_content = """nsec,read_clat_ns_count,read_clat_ns_cumulative,read_clat_ns_percentile
19791872,1,1,0.00016775708773695687
22413312,4,5,0.0008387854386847844
22675456,9,14,0.0023485992283173965
"""
path = tmp_path / "test.csv"
path.write_text(csv_content)
return path
@pytest.fixture
def empty_csv_path(tmp_path):
"""Create an empty CSV file for testing."""
path = tmp_path / "empty.csv"
path.write_text("nsec\n")
return path
@pytest.fixture
def invalid_csv_path(tmp_path):
"""Create an invalid CSV file for testing."""
path = tmp_path / "invalid.csv"
path.write_text("col1,col2\n1,2\n")
return path
+75
View File
@@ -0,0 +1,75 @@
"""Tests for helper functions."""
import pandas as pd
import pytest
from disk_checker.core.helpers import human_count, percentile_table
class TestHumanCount:
"""Tests for human_count function."""
def test_small_number(self):
"""Test formatting of small numbers."""
assert human_count(100) == "100"
assert human_count(50) == "50"
def test_thousands(self):
"""Test formatting of thousands."""
assert human_count(1500) == "1k"
assert human_count(2500) == "3k"
def test_millions(self):
"""Test formatting of millions."""
assert human_count(1500000) == "1.5M"
assert human_count(2750000) == "2.8M"
def test_billions(self):
"""Test formatting of billions."""
assert human_count(1500000000) == "1.5G"
assert human_count(2750000000) == "2.8G"
def test_zero(self):
"""Test formatting of zero."""
assert human_count(0) == "0"
def test_position_argument_ignored(self):
"""Test that _pos argument is ignored."""
assert human_count(1000, 0) == "1k"
class TestPercentileTable:
"""Tests for percentile_table function."""
def test_basic_percentiles(self):
"""Test basic percentile table generation."""
df = pd.DataFrame({
"metric": ["read_clat"] * 3,
"latency_ns": [1000, 2000, 3000],
"count": [1, 2, 1],
})
result = percentile_table(df, "read_clat", "us", [50, 90])
assert "Metric: read_clat" in result
assert "total I/Os: 4" in result
assert "percentile" in result
assert "latency" in result
def test_missing_metric(self):
"""Test percentile table with missing metric."""
df = pd.DataFrame({
"metric": ["read_clat"],
"latency_ns": [1000],
"count": [1],
})
result = percentile_table(df, "write_clat", "us", [50])
assert "(no data for metric write_clat)" in result
def test_unit_display(self):
"""Test that correct unit is displayed."""
df = pd.DataFrame({
"metric": ["read_clat"],
"latency_ns": [1000000], # 1ms in ns
"count": [1],
})
result = percentile_table(df, "read_clat", "ms", [50])
assert "ms" in result
+51
View File
@@ -0,0 +1,51 @@
"""Tests for the orchestrator module."""
import pytest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from disk_checker.core.orchestrator import Orchestrator, ToolStatus, JobResult
class TestOrchestrator:
"""Tests for Orchestrator class."""
@pytest.fixture
def orchestrator(self, tmp_path):
"""Create an orchestrator instance."""
return Orchestrator(work_dir=tmp_path)
def test_initial_state(self, orchestrator):
"""Test initial orchestrator state."""
assert orchestrator.list_jobs() == {}
def test_run_fio_creates_job(self, orchestrator, tmp_path):
"""Test that run_fio creates a job entry."""
with patch("asyncio.create_subprocess_exec") as mock_exec:
mock_exec.return_value = AsyncMock()
asyncio.get_event_loop().run_until_complete(
orchestrator.run_fio("/dev/sda")
)
jobs = orchestrator.list_jobs()
assert len(jobs) == 1
job = list(jobs.values())[0]
assert job.status == ToolStatus.COMPLETED
assert job.output_path.exists()
def test_get_job_status(self, orchestrator, tmp_path):
"""Test retrieving job status."""
with patch("asyncio.create_subprocess_exec") as mock_exec:
mock_exec.return_value = AsyncMock()
job_id = asyncio.get_event_loop().run_until_complete(
orchestrator.run_fio("/dev/sda")
)
job = orchestrator.get_job_status(job_id)
assert job is not None
assert job.status == ToolStatus.COMPLETED
def test_get_nonexistent_job(self, orchestrator):
"""Test getting status of non-existent job."""
job = orchestrator.get_job_status("nonexistent")
assert job is None