Files
Fenris/tests/test_pruning.py

158 lines
6.1 KiB
Python

"""Raw sample pruning tests.
Spec §3.4, ST-5: Raw samples pruned to 14 days; hour observations and
day aggregates are retained indefinitely.
Issue #74: Boundary anchors required for successor evidence are retained.
"""
import sqlite3
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import init_store
from fenris.pruning import prune_old_samples
@pytest.fixture
def store_conn(tmp_path: Path):
db_path = tmp_path / "test.db"
conn = init_store(db_path)
yield conn
conn.close()
def _insert_sample(conn, ts_iso, device="/dev/nvme0"):
conn.execute(
"INSERT OR IGNORE INTO controller_segments "
"(id, opened_at, identity_key) VALUES (1, '2026-01-01T00:00:00+00:00', 'test')"
)
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
" bytes_written, bytes_read, percentage_used, segment_id, local_tz) "
"VALUES (?, ?, 0, 0, 0, 0, 0, 1, 'UTC')",
(ts_iso, device),
)
conn.commit()
class TestPruneOldSamples:
"""Spec §3.4: Raw samples pruned to 14 days."""
def test_keeps_recent_samples(self, store_conn):
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
# Insert a sample 1 day ago
ts = (now - timedelta(days=1)).isoformat()
_insert_sample(store_conn, ts)
pruned = prune_old_samples(store_conn, now, retention_days=14)
assert pruned == 0
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
assert cursor.fetchone()[0] == 1
def test_keeps_old_samples_without_replacement_evidence(self, store_conn):
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
# Old samples stay until UTC and local-day evidence replace them.
for days_ago in [10, 14, 15]:
ts = (now - timedelta(days=days_ago)).isoformat()
_insert_sample(store_conn, ts)
pruned = prune_old_samples(store_conn, now, retention_days=14)
assert pruned == 0
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
assert cursor.fetchone()[0] == 3
def test_keeps_many_old_samples_without_replacement_evidence(self, store_conn):
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
for days_ago in range(1, 30):
ts = (now - timedelta(days=days_ago)).isoformat()
_insert_sample(store_conn, ts)
pruned = prune_old_samples(store_conn, now, retention_days=14)
assert pruned == 0
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
assert cursor.fetchone()[0] == 29
def test_empty_store_no_error(self, store_conn):
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
pruned = prune_old_samples(store_conn, now, retention_days=14)
assert pruned == 0
def test_hour_observations_not_pruned(self, store_conn):
"""Hour observations are retained indefinitely."""
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
# Insert an old sample and a recent sample
# The old sample is a boundary anchor (needed for derivation)
_insert_sample(store_conn, (now - timedelta(days=20)).isoformat())
_insert_sample(store_conn, (now - timedelta(days=1)).isoformat())
# Insert an old hour observation
store_conn.execute(
"INSERT INTO hour_observations (hour, active_seconds, sample_count) "
"VALUES (?, 3600, 1)",
((now - timedelta(days=20)).replace(hour=0, minute=0, second=0).isoformat(),),
)
store_conn.commit()
prune_old_samples(store_conn, now, retention_days=14)
# Old sample is retained as boundary anchor (needed for derivation)
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
assert cursor.fetchone()[0] == 2
# Hour observation retained
cursor = store_conn.execute("SELECT COUNT(*) FROM hour_observations")
assert cursor.fetchone()[0] == 1
def test_boundary_anchor_retained(self, store_conn):
"""Boundary anchors required for derivation are retained."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample before boundary (2026-09-14T23:55:00)
# is 15 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
_insert_sample(store_conn, "2026-09-14T23:55:00+00:00", 1000000)
# Sample after boundary (2026-09-16T12:30:00)
# is 13 days and 23.5 hours old (within retention)
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
# The boundary anchor should be retained
cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-14T23:55:00+00:00'"
)
assert cursor.fetchone()[0] == 1
def test_hour_rows_alone_do_not_replace_local_day_evidence(self, store_conn):
"""An hour row alone cannot authorize source-sample deletion."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample with derived interval
_insert_sample(store_conn, "2026-09-10T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-10T10:30:00+00:00", 2000000)
# Hour observation exists for the interval
store_conn.execute(
"INSERT INTO hour_observations (hour, active_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES ('2026-09-10T10:00:00+00:00', 3600, 1000000, 0, 1, 1.0)",
)
store_conn.commit()
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
# Keep the source samples until local-day replacement evidence exists.
cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'"
)
assert cursor.fetchone()[0] == 1