158 lines
6.1 KiB
Python
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
|