feat: implement legacy history migration (closes #24)
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
"""Legacy migration tests.
|
||||
|
||||
Tests the idempotent, interruption-safe import of history.jsonl into the
|
||||
observation store.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
|
||||
# Add src to path for imports
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.legacy import import_legacy_history, is_legacy_imported, _parse_history_line
|
||||
from fenris.store import init_store
|
||||
|
||||
|
||||
# Fixtures
|
||||
|
||||
@pytest.fixture
|
||||
def history_fixture() -> str:
|
||||
"""Minimal history.jsonl content with two samples."""
|
||||
samples = [
|
||||
{
|
||||
"timestamp": "2026-08-30T10:00:00Z",
|
||||
"device": "/dev/nvme0",
|
||||
"model": "Samsung SSD 970 EVO Plus 1TB",
|
||||
"serial": "S4EWNX0N123456",
|
||||
"firmware_version": "2B2QEXM7",
|
||||
"capacity_bytes": 1024000000000,
|
||||
"data_units_written": 1000000,
|
||||
"data_units_read": 500000,
|
||||
"percentage_used": 5,
|
||||
"power_on_hours": 8765,
|
||||
"temperature": 35,
|
||||
"available_spare": 100,
|
||||
"media_errors": 0,
|
||||
"power_cycles": 1234,
|
||||
"unsafe_shutdowns": 5,
|
||||
"critical_warning": 0,
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-08-30T11:00:00Z",
|
||||
"device": "/dev/nvme0",
|
||||
"model": "Samsung SSD 970 EVO Plus 1TB",
|
||||
"serial": "S4EWNX0N123456",
|
||||
"firmware_version": "2B2QEXM7",
|
||||
"capacity_bytes": 1024000000000,
|
||||
"data_units_written": 1001000,
|
||||
"data_units_read": 501000,
|
||||
"percentage_used": 5,
|
||||
"power_on_hours": 8766,
|
||||
"temperature": 36,
|
||||
"available_spare": 100,
|
||||
"media_errors": 0,
|
||||
"power_cycles": 1234,
|
||||
"unsafe_shutdowns": 5,
|
||||
"critical_warning": 0,
|
||||
},
|
||||
]
|
||||
return "\n".join(json.dumps(s) for s in samples)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hourly_fixture() -> str:
|
||||
"""Minimal hourly.jsonl content for diffing."""
|
||||
hours = [
|
||||
{
|
||||
"hour": "2026-08-30T10:00:00Z",
|
||||
"bytes_written_delta": 512000000,
|
||||
"bytes_read_delta": 256000000,
|
||||
"sample_count": 1,
|
||||
},
|
||||
{
|
||||
"hour": "2026-08-30T11:00:00Z",
|
||||
"bytes_written_delta": 513000000,
|
||||
"bytes_read_delta": 257000000,
|
||||
"sample_count": 1,
|
||||
},
|
||||
]
|
||||
return "\n".join(json.dumps(h) for h in hours)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_fixture(tmp_path: Path) -> Dict[str, Any]:
|
||||
"""Configuration fixture."""
|
||||
return {
|
||||
"device": "/dev/nvme0",
|
||||
"store_path": str(tmp_path / "observations.db"),
|
||||
"data_dir": str(tmp_path),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock_fixture():
|
||||
"""Injected clock returning fixed time."""
|
||||
class FakeClock:
|
||||
def __init__(self):
|
||||
self.now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
def utcnow(self):
|
||||
return self.now
|
||||
|
||||
return FakeClock()
|
||||
|
||||
|
||||
# Test: Idempotency - second run no-ops
|
||||
|
||||
def test_import_idempotent(
|
||||
history_fixture: str,
|
||||
config_fixture: Dict[str, Any],
|
||||
clock_fixture,
|
||||
):
|
||||
"""Given a store with legacy import marker,
|
||||
when import is called again,
|
||||
then it no-ops."""
|
||||
# Create history file
|
||||
history_path = Path(config_fixture["data_dir"]) / "history.jsonl"
|
||||
history_path.write_text(history_fixture)
|
||||
|
||||
# Initialize store
|
||||
store_path = Path(config_fixture["store_path"])
|
||||
conn = init_store(store_path)
|
||||
|
||||
# First import
|
||||
result1 = import_legacy_history(conn, history_path, clock=clock_fixture)
|
||||
assert result1["ok"] is True
|
||||
assert result1["skipped"] is False
|
||||
|
||||
# Second import (should no-op)
|
||||
history_path2 = Path(config_fixture["data_dir"]) / "history.jsonl"
|
||||
history_path2.write_text(history_fixture)
|
||||
result2 = import_legacy_history(conn, history_path2, clock=clock_fixture)
|
||||
assert result2["ok"] is True
|
||||
assert result2["skipped"] is True
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# Test: Interruption safety - single transaction
|
||||
|
||||
def test_import_single_transaction(
|
||||
history_fixture: str,
|
||||
config_fixture: Dict[str, Any],
|
||||
clock_fixture,
|
||||
):
|
||||
"""Given history.jsonl,
|
||||
when import runs,
|
||||
then the entire import is a single transaction."""
|
||||
# Create history file
|
||||
history_path = Path(config_fixture["data_dir"]) / "history.jsonl"
|
||||
history_path.write_text(history_fixture)
|
||||
|
||||
# Initialize store
|
||||
store_path = Path(config_fixture["store_path"])
|
||||
conn = init_store(store_path)
|
||||
|
||||
# Import
|
||||
result = import_legacy_history(conn, history_path, clock=clock_fixture)
|
||||
assert result["ok"] is True
|
||||
|
||||
# Verify all data was imported atomically
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||
assert cursor.fetchone()[0] == 2
|
||||
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM hour_observations")
|
||||
assert cursor.fetchone()[0] == 2
|
||||
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM monitoring_periods")
|
||||
assert cursor.fetchone()[0] == 1
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# Test: Legacy files renamed to *.migrated after commit
|
||||
|
||||
def test_legacy_files_renamed(
|
||||
history_fixture: str,
|
||||
config_fixture: Dict[str, Any],
|
||||
clock_fixture,
|
||||
):
|
||||
"""Given history.jsonl and hourly.jsonl,
|
||||
when import commits,
|
||||
then files are renamed to *.migrated."""
|
||||
# Create files
|
||||
history_path = Path(config_fixture["data_dir"]) / "history.jsonl"
|
||||
history_path.write_text(history_fixture)
|
||||
|
||||
hourly_path = Path(config_fixture["data_dir"]) / "hourly.jsonl"
|
||||
hourly_path.write_text("{}")
|
||||
|
||||
# Initialize store
|
||||
store_path = Path(config_fixture["store_path"])
|
||||
conn = init_store(store_path)
|
||||
|
||||
# Import
|
||||
result = import_legacy_history(conn, history_path, hourly_path=hourly_path, clock=clock_fixture)
|
||||
assert result["ok"] is True
|
||||
|
||||
# Verify files renamed
|
||||
assert not history_path.exists()
|
||||
assert history_path.with_suffix(history_path.suffix + ".migrated").exists()
|
||||
|
||||
assert not hourly_path.exists()
|
||||
assert hourly_path.with_suffix(hourly_path.suffix + ".migrated").exists()
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# Test: Malformed lines quarantined with logged count
|
||||
|
||||
def test_malformed_lines_quarantined(
|
||||
config_fixture: Dict[str, Any],
|
||||
clock_fixture,
|
||||
):
|
||||
"""Given history.jsonl with malformed lines,
|
||||
when import runs,
|
||||
then malformed lines are quarantined with logged count."""
|
||||
# Create history with malformed lines
|
||||
history_content = "\n".join([
|
||||
'{"timestamp": "2026-08-30T10:00:00Z", "model": "Test", "serial": "123", "firmware_version": "1.0", "data_units_written": 1000, "data_units_read": 500, "percentage_used": 5, "power_on_hours": 100, "temperature": 35}',
|
||||
'NOT JSON',
|
||||
'{"timestamp": "2026-08-30T11:00:00Z", "model": "Test", "serial": "123", "firmware_version": "1.0", "data_units_written": 1001, "data_units_read": 501, "percentage_used": 5, "power_on_hours": 101, "temperature": 36}',
|
||||
])
|
||||
history_path = Path(config_fixture["data_dir"]) / "history.jsonl"
|
||||
history_path.write_text(history_content)
|
||||
|
||||
# Initialize store
|
||||
store_path = Path(config_fixture["store_path"])
|
||||
conn = init_store(store_path)
|
||||
|
||||
# Import
|
||||
result = import_legacy_history(conn, history_path, clock=clock_fixture)
|
||||
assert result["ok"] is True
|
||||
assert result["malformed_lines"] == 1
|
||||
assert result["samples_imported"] == 2
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# Test: hourly.jsonl diffed and logged but never trusted
|
||||
|
||||
def test_hourly_jsonl_diffed(
|
||||
history_fixture: str,
|
||||
hourly_fixture: str,
|
||||
config_fixture: Dict[str, Any],
|
||||
clock_fixture,
|
||||
):
|
||||
"""Given history.jsonl and hourly.jsonl with mismatches,
|
||||
when import runs,
|
||||
then mismatches are diffed and logged."""
|
||||
# Create files
|
||||
history_path = Path(config_fixture["data_dir"]) / "history.jsonl"
|
||||
history_path.write_text(history_fixture)
|
||||
|
||||
hourly_path = Path(config_fixture["data_dir"]) / "hourly.jsonl"
|
||||
hourly_path.write_text(hourly_fixture)
|
||||
|
||||
# Initialize store
|
||||
store_path = Path(config_fixture["store_path"])
|
||||
conn = init_store(store_path)
|
||||
|
||||
# Import (should not fail even with mismatches)
|
||||
result = import_legacy_history(conn, history_path, hourly_path=hourly_path, clock=clock_fixture)
|
||||
assert result["ok"] is True
|
||||
|
||||
# Verify data was imported from history.jsonl, not hourly.jsonl
|
||||
cursor = conn.execute("SELECT bytes_written_delta FROM hour_observations ORDER BY hour")
|
||||
deltas = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
# Should match history.jsonl derived values, not hourly.jsonl
|
||||
assert len(deltas) == 2
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# Test: Legacy identity is mn-only
|
||||
|
||||
def test_legacy_identity_mn_only(
|
||||
history_fixture: str,
|
||||
config_fixture: Dict[str, Any],
|
||||
clock_fixture,
|
||||
):
|
||||
"""Given history.jsonl,
|
||||
when import runs,
|
||||
then legacy segment has mn-only identity."""
|
||||
# Create history file
|
||||
history_path = Path(config_fixture["data_dir"]) / "history.jsonl"
|
||||
history_path.write_text(history_fixture)
|
||||
|
||||
# Initialize store
|
||||
store_path = Path(config_fixture["store_path"])
|
||||
conn = init_store(store_path)
|
||||
|
||||
# Import
|
||||
result = import_legacy_history(conn, history_path, clock=clock_fixture)
|
||||
assert result["ok"] is True
|
||||
assert result["legacy_identity_key"] == "legacy|Samsung SSD 970 EVO Plus 1TB"
|
||||
|
||||
# Verify segment has mn-only identity
|
||||
cursor = conn.execute("SELECT identity_key, mn, sn, subnqn FROM controller_segments")
|
||||
row = cursor.fetchone()
|
||||
assert row[0] == "legacy|Samsung SSD 970 EVO Plus 1TB"
|
||||
assert row[1] == "Samsung SSD 970 EVO Plus 1TB"
|
||||
assert row[2] is None # sn is None for legacy
|
||||
assert row[3] is None # subnqn is None for legacy
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# Test: No synthetic baseline created
|
||||
|
||||
def test_no_synthetic_baseline(
|
||||
history_fixture: str,
|
||||
config_fixture: Dict[str, Any],
|
||||
clock_fixture,
|
||||
):
|
||||
"""Given history.jsonl,
|
||||
when import runs,
|
||||
then no endurance baseline is created."""
|
||||
# Create history file
|
||||
history_path = Path(config_fixture["data_dir"]) / "history.jsonl"
|
||||
history_path.write_text(history_fixture)
|
||||
|
||||
# Initialize store
|
||||
store_path = Path(config_fixture["store_path"])
|
||||
conn = init_store(store_path)
|
||||
|
||||
# Import
|
||||
result = import_legacy_history(conn, history_path, clock=clock_fixture)
|
||||
assert result["ok"] is True
|
||||
|
||||
# Verify no baseline created
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM endurance_baseline")
|
||||
assert cursor.fetchone()[0] == 0
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# Test: Monitoring period opened and closed
|
||||
|
||||
def test_monitoring_period_opened_closed(
|
||||
history_fixture: str,
|
||||
config_fixture: Dict[str, Any],
|
||||
clock_fixture,
|
||||
):
|
||||
"""Given history.jsonl,
|
||||
when import runs,
|
||||
then one monitoring period is opened at first sample and closed at migration."""
|
||||
# Create history file
|
||||
history_path = Path(config_fixture["data_dir"]) / "history.jsonl"
|
||||
history_path.write_text(history_fixture)
|
||||
|
||||
# Initialize store
|
||||
store_path = Path(config_fixture["store_path"])
|
||||
conn = init_store(store_path)
|
||||
|
||||
# Import
|
||||
result = import_legacy_history(conn, history_path, clock=clock_fixture)
|
||||
assert result["ok"] is True
|
||||
|
||||
# Verify monitoring period
|
||||
cursor = conn.execute("SELECT started_at, ended_at, end_cause FROM monitoring_periods")
|
||||
row = cursor.fetchone()
|
||||
assert row[0] == "2026-08-30T10:00:00+00:00" # First sample time
|
||||
assert row[1] == clock_fixture.now.isoformat() # Migration time
|
||||
assert row[2] == "migrated"
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# Test: Parse history line
|
||||
|
||||
def test_parse_history_line_valid():
|
||||
"""Given a valid history line,
|
||||
when parsed,
|
||||
then returns the record."""
|
||||
line = '{"timestamp": "2026-08-30T10:00:00Z", "model": "Test", "serial": "123", "firmware_version": "1.0", "data_units_written": 1000, "data_units_read": 500, "percentage_used": 5, "power_on_hours": 100, "temperature": 35}'
|
||||
result = _parse_history_line(line, 1)
|
||||
assert result is not None
|
||||
assert result["model"] == "Test"
|
||||
|
||||
|
||||
def test_parse_history_line_malformed():
|
||||
"""Given a malformed JSON line,
|
||||
when parsed,
|
||||
then returns None."""
|
||||
result = _parse_history_line("NOT JSON", 1)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_parse_history_line_missing_field():
|
||||
"""Given a line with missing required field,
|
||||
when parsed,
|
||||
then returns None."""
|
||||
line = '{"timestamp": "2026-08-30T10:00:00Z", "model": "Test"}'
|
||||
result = _parse_history_line(line, 1)
|
||||
assert result is None
|
||||
Reference in New Issue
Block a user